Power Distribution
Fine on the bench, then dead in the room
Every part of this volume has been proved on its own, with a bench supply behind it. The eye sweeps. The ring breathes. The arm homes and folds. Then all of it goes on one battery, you ask her to look left, answer a question and lift a cup at the same time, and the Pi reboots. No traceback, no log line, nothing in the journal past the boot banner. Whatever killed her happened in the electrical domain, where none of your code was looking.
Here is what those milliseconds contained. A servo starting from rest pulls several times its running current, because a motor that is not yet turning is close to a short circuit across its own winding. Four joints starting together, on the same rail as a light ring that just went from dim amber to white, is a step of several amps inside a millisecond. Every conductor between the cells and the servo has resistance, and so does the pack. Current times resistance is volts, and those volts come off the top of whatever arrives at the far end. Sit under roughly 4.6 V for long enough and a servo misbehaves; do it on the rail feeding the Pi and the board resets with the card open for writing.
So the rule the rest of the build runs on: size every rail for the worst load you will ever command, not the worst load that could physically happen, then give the machine an instrument that reads its own volts while the load is happening.
A Raspberry Pi 4B, a 2S lithium pack (7.4 V nominal, 8.4 V full, 5000 mAh) on an XT60 connector, and two step-down converters hanging off it: a 5 A one for the Pi and its audio, an 8 A one for the servos and the ring. All three grounds are common. The instrument is an MCP3008, a ten-bit converter with eight inputs, powered from 3.3 V with a 100 nF capacitor across its supply pins next to the body of the chip. Its VREF pin goes to 3.3 V as well, never to 5 V. Two resistor dividers feed it: 10 k and 3.9 k from the pack into CH0, 10 k and 10 k from the motion rail into CH1. A multimeter checks the first reading, because an instrument nobody checked against a second instrument is a rumour.
The ADC needs an SPI bus, and GPIO 10 is already carrying the light ring's data with
no chip select to hide behind: anything else transmitting on that pin arrives at the
ring as pixel colours. So the ring keeps SPI0 to itself and the ADC gets a second
controller. Add one line, dtoverlay=spi4-1cs, to
/boot/firmware/config.txt underneath the dtoverlay=spi0-0cs
the ring already runs on. After a reboot /dev/spidev4.0 exists and the
MCP3008 wires to GPIO 4 for chip select (header pin 7), GPIO 5 for its data out
(pin 29), GPIO 6 for its data in (pin 31) and GPIO 7 for the clock (pin 26). That last
pin is one of the two the ring's overlay declined to claim, which is why it was sitting
there free. Every current and voltage printed below came off one bench, one pack and
one set of resistors. Yours will land somewhere else.
Add it up before you cut a wire
A power budget is never one number, because nothing on the machine draws one. Each load has three currents to write down: what it takes powered but doing nothing, what it takes doing its ordinary job, and the worst it can physically do. For a servo that last column is a stall. For the ring it is twenty four pixels at full white. For the amplifier it is the loudest note into the lowest impedance it will ever see. Three columns per load, grouped by which rail feeds it, is the entire budget.
# labs/power_budget.py
from dataclasses import dataclass
@dataclass(frozen=True)
class Load:
"""One thing that draws current, in the three states it can be in."""
name: str
rail: str
idle_ma: float # powered, doing nothing
active_ma: float # doing its ordinary job, measured with a meter in series
peak_ma: float # the worst it can do: stalled, full white, loudest note
LOADS = [
Load("pi 4b", "logic", 600, 950, 1200),
Load("mic array", "logic", 100, 120, 150),
Load("amplifier", "logic", 3, 420, 1400),
Load("adc + sense", "logic", 1, 1, 2),
Load("led ring", "motion", 24, 430, 1440),
Load("eye pan", "motion", 10, 250, 700),
Load("eye tilt", "motion", 10, 250, 700),
Load("arm shoulder", "motion", 10, 900, 2500),
Load("arm elbow", "motion", 10, 900, 2500),
Load("arm wrist", "motion", 10, 900, 2500),
Load("arm gripper", "motion", 10, 400, 1200),
]
SUPPLY_MA = {"logic": 5000.0, "motion": 8000.0} # what each converter is rated for
DERATE = 0.80 # how much of a rating you spend
# A scenario names the column each load is read from. "*" is the default.
SCENARIOS = {
"idle": {"*": "idle_ma"},
"speaking": {"*": "idle_ma", "pi 4b": "active_ma", "mic array": "active_ma",
"amplifier": "active_ma", "led ring": "active_ma",
"eye pan": "active_ma", "eye tilt": "active_ma"},
"arm reach": {"*": "idle_ma", "led ring": "active_ma",
"arm shoulder": "active_ma", "arm elbow": "active_ma"},
"everything": {"*": "peak_ma"},
}
def draw_ma(load: Load, scenario: dict[str, str]) -> float:
"""What this load takes in this scenario."""
return getattr(load, scenario.get(load.name, scenario["*"]))
def rail_draw_ma(rail: str, scenario: dict[str, str]) -> float:
"""Every load on one rail, added up for one scenario."""
return sum(draw_ma(load, scenario) for load in LOADS if load.rail == rail)
def within_budget(rail: str, draw_ma: float) -> bool:
"""True when the draw fits under the derated rating of that rail's converter."""
return draw_ma <= SUPPLY_MA[rail] * DERATE
if __name__ == "__main__":
rails = sorted(SUPPLY_MA)
print(f"{'scenario':<12}" + "".join(f"{rail + ' rail':>19}" for rail in rails))
for name, scenario in SCENARIOS.items():
row = f"{name:<12}"
for rail in rails:
total = rail_draw_ma(rail, scenario)
verdict = "ok" if within_budget(rail, total) else "OVER"
row += f"{total / 1000:>11.2f} A {verdict:<5}"
print(row.rstrip())
print()
for rail in rails:
print(f"{rail:<7} converter {SUPPLY_MA[rail] / 1000:.1f} A rated,"
f" {SUPPLY_MA[rail] * DERATE / 1000:.1f} A budget at {DERATE:.0%} derating")
$ uv run python -m labs.power_budget
scenario logic rail motion rail
idle 0.70 A ok 0.08 A ok
speaking 1.49 A ok 0.97 A ok
arm reach 0.70 A ok 2.27 A ok
everything 2.75 A ok 11.54 A OVER
logic converter 5.0 A rated, 4.0 A budget at 80% derating
motion converter 8.0 A rated, 6.4 A budget at 80% derating
The last row is the one people misread. 11.54 A is every servo stalled at the same instant with the ring at full white and the speaker at full volume, and nothing in her software ever intentionally asks for that. A stalled mechanism can still create it. The motion converter cannot sustain that row; calibrated joint limits and sequencing reduce ordinary demand but cannot interrupt a short circuit or a jam. The three rows that are commands all sit inside the budget with room over, which is the answer you want before any wire gets cut. The 80 percent derating is there because a converter's rating is a maximum, not a comfortable operating point: run one at its number and it gets hot, and hot converters droop.
This table sizes normal operation. Before energizing a build, have its actual power design reviewed for fault-current interruption: appropriate fuses or other rated protective devices must protect the pack, conductors and branches, with converter current limiting and battery protection checked against their documented behavior. Select ratings and placement for the actual cells, wiring and loads; this chapter's current totals do not specify them. Never test a fault by deliberately shorting a pack.
Provide a reachable, independently wired motion-power stop that works when Python,
the controller or the communication link hangs. Review what removing power does to
the load: an unsupported arm may fall, so the mechanism may also need restraint.
A software relax(), a polling alarm and the ninety-second sign-off do
not replace that stop or fault protection. Keep body tests supervised.
A rating is also measured in a particular place. Rated current is what the converter can hold at its own output terminals. Delivered voltage is what exists where the load actually sits, which on this machine is a bracket at the far end of a cable.
# labs/wire_drop.py
from labs.power_budget import SCENARIOS, rail_draw_ma
# Copper resistance of one conductor, milliohms per metre, at room temperature.
MILLIOHMS_PER_M = {24: 84.2, 22: 52.9, 20: 33.3, 18: 20.9, 16: 13.2}
FEED_LENGTH_M = 0.35 # converter to the servo bracket, one way
RAIL_VOLTS = 5.0
SERVO_MIN_V = 4.60 # below this the servos in this build start misbehaving
def loop_milliohms(awg: int, one_way_m: float) -> float:
"""Current goes out and comes back, so both conductors count."""
return MILLIOHMS_PER_M[awg] * one_way_m * 2
def drop_volts(awg: int, one_way_m: float, amps: float) -> float:
"""Ohm's law on the feed: the volts the wire keeps for itself."""
return loop_milliohms(awg, one_way_m) / 1000 * amps
if __name__ == "__main__":
reach = rail_draw_ma("motion", SCENARIOS["arm reach"]) / 1000
worst = rail_draw_ma("motion", SCENARIOS["everything"]) / 1000
print(f"motion rail feed: {FEED_LENGTH_M} m each way, {RAIL_VOLTS:.2f} V at the converter")
print(f"{'awg':>4}{'loop':>11}{'arm reach':>23}{'everything':>23}")
for awg in sorted(MILLIOHMS_PER_M):
d_reach = drop_volts(awg, FEED_LENGTH_M, reach)
d_worst = drop_volts(awg, FEED_LENGTH_M, worst)
end_reach = RAIL_VOLTS - d_reach
end_worst = RAIL_VOLTS - d_worst
print(f"{awg:>4}{loop_milliohms(awg, FEED_LENGTH_M):>8.1f} mR"
f"{d_reach * 1000:>10.0f} mV -> {end_reach:.2f} V"
f"{d_worst * 1000:>10.0f} mV -> {end_worst:.2f} V"
f"{'' if end_worst >= SERVO_MIN_V else ' under 4.60 V'}")
$ uv run python -m labs.wire_drop
motion rail feed: 0.35 m each way, 5.00 V at the converter
awg loop arm reach everything
16 9.2 mR 21 mV -> 4.98 V 107 mV -> 4.89 V
18 14.6 mR 33 mV -> 4.97 V 169 mV -> 4.83 V
20 23.3 mR 53 mV -> 4.95 V 269 mV -> 4.73 V
22 37.0 mR 84 mV -> 4.92 V 427 mV -> 4.57 V under 4.60 V
24 58.9 mR 134 mV -> 4.87 V 680 mV -> 4.32 V under 4.60 V
22 AWG is what comes attached to a servo extension lead, and for one eye servo it is fine. The same wire feeding seven things is not. Current leaves on one conductor and returns on the other, so the loop is twice the length of the run, and the fault column in 22 AWG puts the servo end below its floor while the converter is still doing everything right. Move the feed to 18 AWG and the cable costs 33 mV during a reach instead of 84. The other half of the fix is geometry: shorten the run. Both are free now and expensive once the cable is inside a printed shell with the arm over it.
Volts the program can read
The Pi has no analog input at all. A GPIO pin can tell high from low and nothing in between, so a battery voltage has to be converted somewhere else and handed over as a number. An MCP3008 does that on eight inputs: it compares an input against a reference voltage and reports where that input falls between zero and the reference, as a count from 0 to 1023. Ten bits, so one count is 3.3 volts divided by 1023, a little over 3 mV.
Two scalings stack up. The pack reaches 8.4 V and the reference is 3.3 V, so two resistors in series across the pack make a smaller copy of it at their junction: the bottom resistor's share of the pair. Reading the pack back means undoing both steps in order. Multiply the count by the reference over full scale to recover the junction voltage, then divide by the divider ratio to recover the pack. Get that order backwards and the answer is still a plausible-looking voltage, which is the trap.
# labs/power_sense.py
from dataclasses import dataclass
VREF = 3.3 # the MCP3008's reference, tied to the Pi's 3.3 V pin
ADC_MAX = 1023 # a 10-bit converter counts 0 to 1023, not 0 to 1024
MAX_SOURCE_OHMS = 10_000 # what the chip's sampling capacitor can charge from
@dataclass(frozen=True)
class Sense:
"""One divider feeding one ADC channel."""
name: str
channel: int
r_top: float # from the thing being measured to the junction
r_bottom: float # from the junction to ground
rail_max_v: float # the highest this point should ever legitimately reach
@property
def ratio(self) -> float:
return self.r_bottom / (self.r_top + self.r_bottom)
@property
def full_scale_v(self) -> float:
"""The input voltage that would put the junction exactly at VREF."""
return VREF / self.ratio
@property
def source_ohms(self) -> float:
"""What the ADC sees looking back into the junction: the two in parallel."""
return self.r_top * self.r_bottom / (self.r_top + self.r_bottom)
def problems(self) -> list[str]:
out = []
if self.rail_max_v > self.full_scale_v:
out.append(f"{self.name}: {self.rail_max_v:.2f} V is past the "
f"{self.full_scale_v:.2f} V full scale of this divider")
if self.source_ohms > MAX_SOURCE_OHMS:
out.append(f"{self.name}: {self.source_ohms:.0f} ohm source is above the "
f"{MAX_SOURCE_OHMS} ohm the chip can sample cleanly")
return out
SENSES = {
"pack": Sense("pack", 0, 10_000, 3_900, rail_max_v=8.40),
"motion": Sense("motion", 1, 10_000, 10_000, rail_max_v=5.30),
}
# Measured on one 2S pack, resting, with the machine idle. Descending.
LIPO_2S = [
(8.40, 100), (8.20, 90), (8.00, 78), (7.80, 64), (7.60, 49),
(7.40, 33), (7.20, 18), (7.00, 6), (6.60, 0),
]
def adc_to_volts(raw: int, sense: Sense) -> float:
"""Raw count -> junction volts -> the volts on the other side of the divider."""
junction_v = raw / ADC_MAX * VREF
return junction_v / sense.ratio
def capacity_percent(volts: float, table: list[tuple[float, int]] = LIPO_2S) -> int:
"""State of charge, interpolated between the two rows this voltage falls between."""
if volts >= table[0][0]:
return table[0][1]
if volts <= table[-1][0]:
return table[-1][1]
for (v_hi, c_hi), (v_lo, c_lo) in zip(table, table[1:]):
if v_lo <= volts <= v_hi:
fraction = (volts - v_lo) / (v_hi - v_lo)
return round(c_lo + fraction * (c_hi - c_lo))
return table[-1][1]
if __name__ == "__main__":
for sense in SENSES.values():
print(f"{sense.name:<7} ch{sense.channel} {sense.r_top:.0f}/{sense.r_bottom:.0f} ohm"
f" ratio {sense.ratio:.4f} full scale {sense.full_scale_v:5.2f} V"
f" source {sense.source_ohms:.0f} ohm")
for problem in sense.problems():
print(f" PROBLEM {problem}")
print()
for raw in (689, 671, 640, 597):
volts = adc_to_volts(raw, SENSES["pack"])
print(f"pack raw {raw} -> {volts:5.2f} V -> {capacity_percent(volts):3d}%")
for raw in (778, 755):
print(f"motion raw {raw} -> {adc_to_volts(raw, SENSES['motion']):5.2f} V")
$ uv run python -m labs.power_sense
pack ch0 10000/3900 ohm ratio 0.2806 full scale 11.76 V source 2806 ohm
motion ch1 10000/10000 ohm ratio 0.5000 full scale 6.60 V source 5000 ohm
pack raw 689 -> 7.92 V -> 72%
pack raw 671 -> 7.71 V -> 57%
pack raw 640 -> 7.36 V -> 30%
pack raw 597 -> 6.86 V -> 4%
motion raw 778 -> 5.02 V
motion raw 755 -> 4.87 V
Three properties earn their place. ratio is the arithmetic the divider
performs. full_scale_v answers a question the ratio hides: what input
would put the junction exactly at the reference, and therefore how much headroom sits
above the highest legal reading. The pack divider tops out at 11.76 V against a pack
that should never exceed 8.40, which is deliberate slack for a charger left connected
or a wrong pack plugged in. source_ohms is the property nobody expects to
matter, and it is the whole of the next few paragraphs.
The capacity table is measured, not derived. A lithium cell's discharge curve is flat through the middle and steep at both ends, so nine anchor points with a straight line between neighbours fits it where no single formula does. 7.71 V lands between the 7.80 row and the 7.60 row and comes back 57 percent, not the 64 a nearest-row lookup would report. The two clamps matter as much as the interpolation: outside the table the loop has no pair to sit between, and an over-voltage reading would fall out as zero.
The pack divider sits across the battery forever: 8.4 V across 13.9 k is 0.60 mA, about 14 mAh a day, enough to flatten a pack left on a shelf. The obvious economy is to multiply both resistors by a hundred. Same ratio, same arithmetic, six microamps of drain.
# labs/power_sense.py -- the "stop wasting half a milliamp" edit
SENSES = {
"pack": Sense("pack", 0, 1_000_000, 390_000, rail_max_v=8.40),
"motion": Sense("motion", 1, 1_000_000, 1_000_000, rail_max_v=5.30),
}
$ uv run python -m labs.power_monitor # measured on the bench, yours will vary
19:41:02 pack 8.76 V (sag 8.71) motion 4.50 V (sag 4.46) 100%
19:41:12 pack 8.75 V (sag 8.70) motion 4.51 V (sag 4.47) 100%
19:41:22 pack 8.77 V (sag 8.69) motion 4.50 V (sag 4.45) 100%
The multimeter says 7.92 V at the pack and 5.02 V at the motion rail. The ADC says 8.76 and 4.50. Both are wrong, and they are wrong in opposite directions, each pulled toward the other. That detail is the whole diagnosis. An MCP3008 does not have eight converters; it has one, behind a switch, and in front of it a small sampling capacitor of about 20 pF that has to be charged to the input voltage before the conversion starts. Charging it is the divider's job, and the time available is roughly one and a half clock cycles, near 1.1 µs at 1.35 MHz. With 2.8 k of source that is many time constants and the capacitor arrives easily. With 281 k the time constant is about 5.6 µs, so the capacitor moves less than a fifth of the way from wherever the previous channel left it, and every reading carries the last one along with it.
The symptom names the cause exactly: a reading that depends on which channel was read before it is a reading taken from a capacitor that never finished charging. The fix is to put the low-value resistors back and pay the 0.60 mA, or to switch the divider off with a small MOSFET when the machine is stored. Either way the guard belongs in code, because the bench will not volunteer this:
$ uv run python -m labs.power_sense
pack ch0 1000000/390000 ohm ratio 0.2806 full scale 11.76 V source 280576 ohm
PROBLEM pack: 280576 ohm source is above the 10000 ohm the chip can sample cleanly
motion ch1 1000000/1000000 ohm ratio 0.5000 full scale 6.60 V source 500000 ohm
PROBLEM motion: 500000 ohm source is above the 10000 ohm the chip can sample cleanly
One reading is not a trend
A single voltage answers almost nothing. Taken while the arm is lifting it is a sag, and the capacity table turns it into a nearly empty pack that will read healthy again ten seconds later. Taken at rest it says nothing about whether the rail collapses under load. The only way to have both numbers is to sample faster than the machine moves and keep the extremes of a window.
# labs/power_monitor.py
import json
import time
from datetime import datetime, timezone
from pathlib import Path
import spidev
from labs.power_sense import SENSES, Sense, adc_to_volts, capacity_percent
from labs.wire_drop import SERVO_MIN_V
SPI_BUS, SPI_DEVICE = 4, 0 # /dev/spidev4.0, the ADC's own bus
SPI_HZ = 1_350_000 # the MCP3008's ceiling at a 3.3 V reference
SAMPLE_HZ = 50 # how often each channel is looked at
REPORT_S = 10.0 # how much history each printed line covers
LOG_PATH = Path("glados/data/power_log.jsonl")
def read_channel(spi: spidev.SpiDev, channel: int) -> int:
"""One single-ended conversion. Returns 0-1023."""
if not 0 <= channel <= 7:
raise ValueError(f"MCP3008 has channels 0-7, got {channel}")
reply = spi.xfer2([0x01, (0x08 | channel) << 4, 0x00])
return ((reply[1] & 0x03) << 8) | reply[2]
def summarise(raws: list[int], sense: Sense) -> tuple[float, float]:
"""The least-loaded and most-loaded volts in a window of samples."""
return adc_to_volts(max(raws), sense), adc_to_volts(min(raws), sense)
def append_log(entry: dict) -> None:
"""One JSON object, one line, appended. A half-written line loses one sample."""
LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
with LOG_PATH.open("a") as f:
f.write(json.dumps(entry) + "\n")
def main() -> None:
spi = spidev.SpiDev()
spi.open(SPI_BUS, SPI_DEVICE)
spi.max_speed_hz = SPI_HZ
window: dict[str, list[int]] = {name: [] for name in SENSES}
deadline = time.monotonic() + REPORT_S
try:
while True:
for name, sense in SENSES.items():
window[name].append(read_channel(spi, sense.channel))
time.sleep(1 / SAMPLE_HZ)
if time.monotonic() < deadline:
continue
pack_rest, pack_sag = summarise(window["pack"], SENSES["pack"])
rail_rest, rail_sag = summarise(window["motion"], SENSES["motion"])
entry = {
"t": datetime.now(timezone.utc).isoformat(timespec="seconds"),
"pack_rest_v": round(pack_rest, 2), "pack_sag_v": round(pack_sag, 2),
"rail_rest_v": round(rail_rest, 2), "rail_sag_v": round(rail_sag, 2),
"capacity_pct": capacity_percent(pack_rest),
}
append_log(entry)
warning = " MOTION RAIL BELOW SPEC" if rail_sag < SERVO_MIN_V else ""
print(f"{entry['t'][11:19]} pack {pack_rest:.2f} V (sag {pack_sag:.2f})"
f" motion {rail_rest:.2f} V (sag {rail_sag:.2f})"
f" {entry['capacity_pct']:3d}%{warning}")
window = {name: [] for name in SENSES}
deadline = time.monotonic() + REPORT_S
except KeyboardInterrupt:
pass
finally:
spi.close()
if __name__ == "__main__":
main()
$ uv run python -m labs.power_monitor # measured on the bench, yours will vary
19:04:11 pack 7.92 V (sag 7.92) motion 5.02 V (sag 5.02) 72%
19:04:21 pack 7.92 V (sag 7.83) motion 5.02 V (sag 4.94) 72%
19:04:31 pack 7.92 V (sag 7.71) motion 5.02 V (sag 4.87) 72%
19:04:41 pack 7.90 V (sag 7.44) motion 5.01 V (sag 4.58) 71% MOTION RAIL BELOW SPEC
19:04:51 pack 7.91 V (sag 7.90) motion 5.02 V (sag 5.02) 72%
The highest sample in a window is the closest thing to a resting reading, so capacity comes from that; the lowest is the deepest sag, so the warning comes from that. Read the third and fourth lines together: the pack barely moved while the motion rail lost almost half a volt and crossed the floor the servos need. The rail is where a brownout actually lives. Capacity also ticks back up on the last line, from 71 to 72, because an unloaded pack recovers a few tens of millivolts. That is chemistry, and a good argument against showing anyone a percentage that jitters.
Fifty samples a second per channel is twenty milliseconds between looks. A servo move lasts a few hundred milliseconds and the window catches it comfortably. The inrush at the very start lasts a couple of milliseconds and this loop will usually miss it entirely. This loop records trends and sampled minima. Capacitors can reduce short droops, but proving their effect needs a scope or faster acquisition with a suitable trigger and bandwidth; the logger cannot certify the intervals it never observed.
The log is JSON Lines: one object, one line, opened for append. This chapter exists because the machine sometimes loses power without warning, so the format has to survive that. Rewriting a whole JSON array every ten seconds leaves an instant where the old file is gone and the new one unfinished; losing power there costs the entire history. Appending costs, at worst, a truncated final line.
$ tail -2 glados/data/power_log.jsonl
{"t": "2026-08-14T19:04:41+00:00", "pack_rest_v": 7.9, "pack_sag_v": 7.44, "rail_rest_v": 5.01, "rail_sag_v": 4.58, "capacity_pct": 71}
{"t": "2026-08-14T19:04:51+00:00", "pack_rest_v": 7.91, "pack_sag_v": 7.9, "rail_rest_v": 5.02, "rail_sag_v": 5.02, "capacity_pct": 72}
Why this works
Every volt that leaves the pack is spent somewhere, and there are only three places for it to go: the pack's own internal resistance together with its connector, the converter's regulation as its feedback loop chases a moving load, and the wire and joints between the converter and the thing drawing current. Ohm's law applies once per place and the results add.
From this bench, with a meter on the pack terminals: 7.92 V resting, 7.71 V while the arm reached, the pack supplying roughly 2.1 A once the converters' losses are counted. That difference over that current is close to 0.10 ohm, mostly the connector and the pack leads, not the cells. On the motion rail, measured at the servo bracket instead of at the converter: 5.02 V at rest and 4.87 V during the same move at 2.27 A, which is 66 milliohm, of which stage 2 already accounted for 37 in the cable. The rest is connectors, solder joints, and a converter giving up a little under load. No one of those is large. They add.
This is also why capacity only ever comes from a resting sample. A loaded pack reads low by exactly the sag, so a charge state taken during an arm move is pessimistic by an amount that depends on what the arm was doing. The sag is a useful signal on its own too, because internal resistance climbs as a pack ages: the same 2.1 A costing 0.30 V instead of 0.21 V is the pack saying it is near the end of its life, months before the runtime gets short enough to notice.
Separate mitigation from measurement. Capacitance near a load can supply a brief current step while the converter responds, but its required value and effectiveness depend on the actual circuit and need measurement. Use suitable transient acquisition for microsecond-to-millisecond behavior; this sampler sees only its roughly twenty millisecond-spaced observations. The log preserves longer trends so you can compare a loaded pack with its recovery after the load ends.
Checkpoint, and the pins that have to agree
- I can read a rail budget and say which column describes something the software commands and which describes a fault.
- I can work out the volts a 0.35 m feed keeps for itself at a given current, and pick a wire gauge from the answer instead of from what was in the drawer.
- I can turn a raw MCP3008 count into pack volts, and say which of the two divisions undoes the resistor divider and which undoes the ten-bit scale.
- Given an ADC reading that changes depending on which channel was read before it, I can name the sampling capacitor as the cause and the source resistance as the cure.
- I can explain why capacity is taken from the highest sample in a window and the brownout warning from the lowest.
- I know what a 1000 µF capacitor covers that a 50 Hz sampler never will, and why appending one line beats rewriting one array.
Exercise 1 — move the ring onto the logic rail. It is one character in the table. Re-run the budget and decide whether you would ship the change.
from dataclasses import replace
import labs.power_budget as budget
from labs.power_budget import SCENARIOS, rail_draw_ma, within_budget
budget.LOADS = [replace(load, rail="logic") if load.name == "led ring" else load
for load in budget.LOADS]
for name, scenario in SCENARIOS.items():
row = f"{name:<12}"
for rail in ("logic", "motion"):
total = rail_draw_ma(rail, scenario)
verdict = "ok" if within_budget(rail, total) else "OVER"
row += f"{rail} {total / 1000:>6.2f} A {verdict:<5}"
print(row.rstrip())
$ uv run python -m labs.ring_on_logic
idle logic 0.73 A ok motion 0.06 A ok
speaking logic 1.92 A ok motion 0.54 A ok
arm reach logic 1.13 A ok motion 1.84 A ok
everything logic 4.19 A OVER motion 10.10 A OVER
Every commanded row still fits and the fault row now breaks both rails instead of one. The stronger objection is not in the table: the ring switches its current on and off at kilohertz rates, and putting that on the rail feeding the amplifier puts it into her voice. Rails get split by noise as much as by amps.
Exercise 2 — how long is too long. Allow the motion feed a budget of 100 mV during an arm reach and print, for each gauge, the longest run that stays inside it.
from labs.power_budget import SCENARIOS, rail_draw_ma
from labs.wire_drop import MILLIOHMS_PER_M
DROP_BUDGET_V = 0.100
amps = rail_draw_ma("motion", SCENARIOS["arm reach"]) / 1000
allowed_milliohms = DROP_BUDGET_V / amps * 1000
print(f"arm reach draws {amps:.2f} A; a {DROP_BUDGET_V * 1000:.0f} mV budget "
f"allows {allowed_milliohms:.1f} mohm of loop resistance")
for awg, per_m in sorted(MILLIOHMS_PER_M.items()):
print(f" {awg} awg -> {allowed_milliohms / (2 * per_m):.2f} m each way")
$ uv run python -m labs.feed_length
arm reach draws 2.27 A; a 100 mV budget allows 44.1 mohm of loop resistance
16 awg -> 1.67 m each way
18 awg -> 1.05 m each way
20 awg -> 0.66 m each way
22 awg -> 0.42 m each way
24 awg -> 0.26 m each way
Measure the route the cable actually takes around the shell, not the straight line between its ends, then read the gauge off this list. A 42 cm limit sounds generous until the cable has been dressed around a servo bracket twice.
Exercise 3 — read the trend back. Write the reader for the log: report how far the pack fell over the session and the worst rail sag in it, skipping any line that a power cut left half written.
import json
from pathlib import Path
LOG_PATH = Path("glados/data/power_log.jsonl")
def load_entries(path: Path = LOG_PATH) -> list[dict]:
"""Read every complete line; a truncated last line is skipped, not fatal."""
entries = []
for line in path.read_text().splitlines():
try:
entries.append(json.loads(line))
except json.JSONDecodeError:
continue
return entries
if __name__ == "__main__":
entries = load_entries()
first, last = entries[0], entries[-1]
worst = min(entries, key=lambda e: e["rail_sag_v"])
print(f"{len(entries)} samples, {first['t'][11:19]} to {last['t'][11:19]}")
print(f"pack {first['pack_rest_v']:.2f} V -> {last['pack_rest_v']:.2f} V"
f" ({first['capacity_pct']}% -> {last['capacity_pct']}%)")
print(f"worst motion sag {worst['rail_sag_v']:.2f} V at {worst['t'][11:19]}")
$ uv run python -m labs.power_report
5 samples, 19:04:11 to 19:04:51
pack 7.92 V -> 7.91 V (72% -> 72%)
worst motion sag 4.58 V at 19:04:41
Leave the monitor running for an hour of ordinary use and the same three lines become a discharge curve you can plan around. The decode guard is not decoration: the line most likely to be malformed is the last one written before the event you are investigating.
Her power is now something the machine can see instead of something you find out about from a reboot. This chapter also claimed four more pins for the ADC on a second SPI controller, and that pin list now lives in several files at once and in no one's head. A pin wired to two devices raises nothing at all; it gives you a servo that jitters and a ring that goes dark. Next comes the single file holding every claim, and the check that finds a collision by counting instead of by tracing wires at midnight.