GLaDOS Vol 7 · The Body
ch 70 / 99
Chapter 70

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.

◆ Note — the bench for this chapter, and the bus the ADC gets

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.

▣ Build · stage 1 — three currents per load, grouped by rail
# 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.

◆ Note — budget, protection and stopping are separate jobs

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.

▣ Build · stage 2 — what the cable keeps for itself
# 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.

One pack, two converters, and the budget each rail carries A 2S lithium pack feeds two step-down converters. The upper one is rated 5 amps and budgeted to 4.00 amps; it supplies the Pi, the microphone array, the amplifier and the ADC, which together draw 0.70 amps idle and 1.49 amps while she is speaking. The lower one is rated 8 amps and budgeted to 6.40 amps; it supplies the light ring, two eye servos and four arm joints through 0.35 metres of 18 AWG wire, drawing 0.08 amps idle, 2.27 amps during an arm reach, and 11.54 amps if everything stalls at once, which is over budget. ONE PACK · TWO CONVERTERS · TWO SEPARATE BUDGETS 2S pack 7.4 V · 5000 mAh 5 V buck, 5 A spend 4.00 A 5 V buck, 8 A spend 6.40 A 0.35 m pi · mics · amp · adc idle 0.70 A speaking 1.49 A ring · 2 eye · 4 arm idle 0.08 A arm reach 2.27 A all stalled 11.54 A · OVER
Figure 70.1 — The split exists so that the loud, spiky half of the machine cannot reach the half that is holding an open file. Both converters share one pack, so a big enough motion transient still shows up on the logic side, just smaller and slower.

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.

▣ Build · stage 3 — the divider, the table, and no hardware in the room
# 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.

⚠ Worked failure — the divider that saved half a milliamp

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.

▣ Build · stage 4 — sample at 50 Hz, report the window, append the line
# 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}
The motion rail during one ten-second reporting window A voltage trace of the 5 volt motion rail over ten seconds. It sits at 5.02 volts while idle, steps down to 4.94 while the eyes move and the ring is lit, returns, steps down to 4.87 during an arm reach, then down to 4.58 while the arm lifts and the ring runs white, which is below the dashed 4.60 volt line the servos need. A narrow spike at the start of the deepest step marks a two millisecond inrush that a fifty hertz sampler never sees. The trace then recovers to 5.02. MOTION RAIL · 5 V NOMINAL · SAMPLED 50 TIMES A SECOND 5.02 4.60 servos need this much idle eyes + ring arm reach arm lift, ring white recovered 2 ms inrush: no 50 Hz sampler sees this one 10 second reporting window
Figure 70.2 — Every step down is current times resistance, and the resistance never changes. Only the current does, which makes the depth of each step a direct readout of how much the machine just asked for.

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

✓ Checkpoint — what you can now do
  • 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.
⚡ Exercises — try first, then reveal
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.