Physical Assembly
The screw you cannot reach
The shell came off the printer in chapter 63 and has been a pile of parts ever since. Everything built after it was built on the bench: one subsystem at a time, jumper leads long enough to reach across a desk, each board sitting where your hands could get to it. Now all of it goes inside a box barely larger than the sum of the parts.
Two hours in you meet the first ordering problem. The shoulder servo bolts to the base plate from underneath, and the Pi's tray sits directly over those four M3 heads. Seat the Pi first and there is nowhere to put a driver: the Pi comes back off, and if you already dressed the ribbon, that comes off too. The head has a smaller version of the same thing, where the LED ring presses into the diffuser recess and covers the two M2 screws holding the tilt bracket. A build this size has a dozen of these, and not one of them announces itself until you are holding the part.
Assembly is also the one phase you cannot re-run in a second. Software you can delete and regenerate; a stripped insert in a printed boss is an evening with a soldering iron and a brass repair kit. So the cost of losing your place is real, and losing your place is the normal case. You stop at eleven at night with the arm half wired and come back two days later. Did you put the inline resistor on the ring's data line? A paper list says nothing. Memory says "probably", and probably is not a thing you want to test by applying power.
Both problems have the same fix, and it is not a longer list. It is putting the dependencies in the list itself: every step names what must already be done before it, so the order is a property a program can check, and every confirmed step is written to disk the moment you confirm it, so your position in the build is a file and not a memory.
Seventeen steps closed up the bench machine: a printed shell, a Pi 4B, two eye servos, the ring, four arm joints on the first of the two PCA9685 boards, the amplifier and mic array, the MCP3008 board, and a 5 V motion rail. The GPIO numbers quoted in the step text are the ones chapter 71 settled, and the pin card that run wrote is what you check them against before a wire goes on a header. Your enclosure will have a step this one lacks (a fan, a switch, a different mic) and will not need one this one has. That is the argument for keeping the plan in a file the program writes and you edit, instead of in the program.
The plan, in a file you are meant to edit
# labs/assembly_plan.py
"""The assembly plan: an ordered list of steps, each naming what must be done first."""
import json
import sys
from pathlib import Path
PLAN_FILE = Path("configs/assembly_steps.json")
DEFAULT_PLAN = [
{"id": "standoffs", "desc": "Press four M2.5 brass standoffs into the base plate.",
"tools": ["M2.5 driver", "standoffs"], "requires": []},
{"id": "mount_shoulder", "desc": "Bolt the MG996R shoulder to the base plate, heads underneath.",
"tools": ["M3 driver"], "requires": []},
{"id": "mount_pi", "desc": "Seat the Pi 4B on the standoffs. Do not overtighten.",
"tools": ["M2.5 driver"], "requires": ["standoffs", "mount_shoulder"],
"why": "the tray covers the shoulder's four M3 heads"},
{"id": "mount_led_ring", "desc": "Press the LED ring into the diffuser recess, JST tail down.",
"tools": ["hot glue"], "requires": ["mount_eye_tilt"],
"why": "the ring sits over the tilt bracket's two M2 screws"},
{"id": "cable_eye", "desc": "Route eye servos to GPIO 12/13 and the ring data line, 330 ohm inline, to GPIO 10.",
"tools": ["zip ties", "heatshrink"], "requires": ["mount_led_ring", "mount_pi"],
"why": "both bundles leave the head through the neck channel"},
# ...twelve more, ending at final_check
]
def load_plan(path: Path = PLAN_FILE) -> list[dict]:
"""Read the plan. On a machine that has never run this, write the default first."""
if not path.exists():
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(DEFAULT_PLAN, indent=2) + "\n")
print(f"wrote {path} ({len(DEFAULT_PLAN)} steps). Edit it to match your build.")
return json.loads(path.read_text())
$ uv run python -m labs.assembly_plan
wrote configs/assembly_steps.json (17 steps). Edit it to match your build.
17 steps, 0 plan problem(s)
$ uv run python -m labs.assembly_plan # second run
17 steps, 0 plan problem(s)
The program writes the file it reads, once, and never again. That removes the setup
ritual nobody remembers three months later, and it makes the JSON the thing you own:
reorder it, delete the fan step, add a rear panel, and the code is untouched. The
requires list is the part doing new work. A flat ordered list already
implies an order, but implies it silently, so an edit that moves a step past its
prerequisite reads exactly like an edit that did not. Naming the prerequisite makes
the order checkable, and it gives every step somewhere to record why it sits
where it does, in the sentence you will want at two in the morning.
# labs/assembly_plan.py — continued
def plan_problems(steps: list[dict]) -> list[str]:
"""Faults you can find before touching hardware: bad ids, and order that contradicts requires."""
problems: list[str] = []
position: dict[str, int] = {}
for i, step in enumerate(steps):
if step["id"] in position:
problems.append(f"step id {step['id']!r} appears twice")
continue
position[step["id"]] = i
for i, step in enumerate(steps):
for need in step.get("requires", []):
if need not in position:
problems.append(f"step {step['id']!r} requires {need!r}, which is not in the plan")
elif position[need] > i:
problems.append(f"step {step['id']!r} at position {i + 1} requires "
f"{need!r} at position {position[need] + 1}")
return problems
if __name__ == "__main__":
steps = load_plan()
problems = plan_problems(steps)
for problem in problems:
print(f" [x] {problem}")
print(f"{len(steps)} steps, {len(problems)} plan problem(s)")
sys.exit(1 if problems else 0)
$ uv run python -m labs.assembly_plan # after an evening of editing the JSON
[x] step 'cable_eye' at position 7 requires 'mount_led_ring' at position 12
[x] step 'cable_audio' requires 'mount_pi4', which is not in the plan
17 steps, 2 plan problem(s)
Two different mistakes, both from the same editing session. The first is an ordering fault: the head cabling got dragged up the file while the ring was left below it, so the plan now tells you to dress a cable that runs from a part you have not fitted. The second is a typo in an id, and it is the one worth catching hardest, because a misspelled prerequisite is a dependency that silently does not exist. Both checks run in a tenth of a second on a laptop, before a single fastener has gone in.
The two passes are not interchangeable. The first builds position for
every id in the file; only then can the second ask whether a named prerequisite exists
and where it sits. Do it in one pass and a step that requires something declared four
lines below it looks identical to a step that requires something declared nowhere, and
you get a fake error on a correct plan.
Where you actually are
# labs/assembly_checklist.py
"""Interactive assembly checklist: saves after every confirmed step, resumes from disk."""
import argparse
import json
import os
import sys
from pathlib import Path
from labs.assembly_plan import load_plan, plan_problems
PROGRESS_FILE = Path("glados/data/assembly_progress.json")
def load_progress(path: Path = PROGRESS_FILE) -> set[str]:
"""The ids already confirmed. A build that has never run is an empty set, not a crash."""
if not path.exists():
return set()
return set(json.loads(path.read_text()).get("done", []))
def save_progress(done: set[str], path: Path = PROGRESS_FILE) -> None:
"""Write the set as a sorted list, and land the file in one move."""
path.parent.mkdir(parents=True, exist_ok=True)
temp = path.with_suffix(".tmp")
temp.write_text(json.dumps({"done": sorted(done)}, indent=2) + "\n")
os.replace(temp, path)
$ uv run python -m labs.progress_demo
loaded []
saved glados/data/assembly_progress.json
{
"done": [
"mount_shoulder",
"standoffs"
]
}
reloaded ['mount_shoulder', 'standoffs']
A set is the right type in memory: membership is what every question asks, and adding
an id twice, which happens the moment you re-confirm a step, cannot duplicate
anything. A set is also not a JSON type, so something has to translate on the way out,
and sorted() does the translation and buys a stable file as well. Two
runs that finish the same steps produce byte-identical JSON, so the file diffs cleanly
and you can read it without a tool.
The temporary file and os.replace are there because of what this file is.
It is the only record of an hour of physical work, and it gets rewritten seventeen
times in an evening. Writing directly means the file spends a few milliseconds
truncated each time, and a crash inside that window leaves nothing readable behind.
os.replace is atomic on a single filesystem: the new file is complete
before it takes the name, and the reader either sees all of the old one or all of the
new one.
# labs/assembly_checklist.py — continued
def partition(steps: list[dict], done: set[str]) -> tuple[list[dict], list[tuple[dict, list[str]]]]:
"""Split what is left into steps you can do now and steps something else is holding up."""
ready: list[dict] = []
blocked: list[tuple[dict, list[str]]] = []
for step in steps:
if step["id"] in done:
continue
missing = [need for need in step.get("requires", []) if need not in done]
if missing:
blocked.append((step, missing))
else:
ready.append(step)
return ready, blocked
def status(steps: list[dict], done: set[str]) -> str:
ready, blocked = partition(steps, done)
lines = [f"{len(done)}/{len(steps)} done, {len(ready)} ready, {len(blocked)} blocked"]
for step in ready:
lines.append(f" ready {step['id']}")
for step, missing in blocked:
lines.append(f" blocked {step['id']:<20} waiting on {', '.join(missing)}")
return "\n".join(lines)
$ uv run python -m labs.assembly_checklist --status
2/17 done, 6 ready, 9 blocked
ready mount_pi
ready mount_pca
ready mount_eye_pan
ready mount_elbow
ready mount_audio
ready mount_adc
blocked mount_eye_tilt waiting on mount_eye_pan
blocked mount_led_ring waiting on mount_eye_tilt
blocked mount_wrist_gripper waiting on mount_elbow
blocked cable_eye waiting on mount_led_ring, mount_pi
blocked cable_arm waiting on mount_wrist_gripper, mount_pca
blocked cable_audio waiting on mount_audio, mount_pi
blocked cable_adc waiting on mount_adc, mount_pi
blocked power_rail waiting on cable_arm, cable_eye, cable_adc
blocked final_check waiting on power_rail, cable_audio
Nothing in the program remembers that two steps are finished. It reads the file, then
derives everything from the plan and that set: done by membership,
ready by a prerequisite test, blocked by what fails the test
and why. Six things you could do next is real information on a build with parts spread
over a desk, and it is information a linear list cannot give you, because a list only
knows what comes next in it.
Walking it with a driver in your hand
# labs/assembly_checklist.py — continued
def walk(steps: list[dict], done: set[str]) -> None:
"""Offer every step whose prerequisites are met, in plan order, saving after each."""
total = len(steps)
for position, step in enumerate(steps, start=1):
if step["id"] in done:
continue
missing = [need for need in step.get("requires", []) if need not in done]
if missing:
print(f"[skip] Step {position}/{total}: {step['id']} is blocked by {', '.join(missing)}")
continue
print(f"[{100 * len(done) // total:3d}%] Step {position}/{total}: {step['desc']}")
print(f" tools: {', '.join(step['tools'])}")
if step.get("why"):
print(f" after: {', '.join(step['requires'])} — {step['why']}")
try:
answer = input(" done? [enter=yes s=skip q=quit]: ").strip().lower()
except (EOFError, KeyboardInterrupt):
answer = "q"
if answer == "q":
print(f"\nstopped at {len(done)}/{total}. Run again to pick this up.")
return
if answer == "s":
print(f" skipped {step['id']}")
continue
done.add(step["id"])
save_progress(done)
remaining = total - len(done)
print(f"\n{len(done)}/{total} done" + ("" if remaining else " — the body is assembled."))
$ uv run python -m labs.assembly_checklist --reset # first evening; the PCA9685 is still in the post
progress cleared
[ 0%] Step 1/17: Press four M2.5 brass standoffs into the base plate.
tools: M2.5 driver, standoffs
done? [enter=yes s=skip q=quit]: [ 5%] Step 2/17: Bolt the MG996R shoulder to the base plate, heads underneath.
tools: M3 driver
done? [enter=yes s=skip q=quit]: [ 11%] Step 3/17: Seat the Pi 4B on the standoffs. Do not overtighten.
tools: M2.5 driver
after: standoffs, mount_shoulder — the tray covers the shoulder's four M3 heads
done? [enter=yes s=skip q=quit]: [ 17%] Step 4/17: Mount the PCA9685 on its own standoffs beside the Pi.
tools: M2.5 driver
after: standoffs — it shares the base plate's standoff row
done? [enter=yes s=skip q=quit]: skipped mount_pca
[ 17%] Step 5/17: Drop the pan servo into the eye socket, horn centred.
tools: M2 driver, servo horn
done? [enter=yes s=skip q=quit]: [ 23%] Step 6/17: Screw the tilt bracket onto the pan horn.
tools: M2 driver
after: mount_eye_pan — the bracket bolts to the horn
done? [enter=yes s=skip q=quit]:
stopped at 4/17. Run again to pick this up.
The --reset on that command threw away the two ids the persistence demo
put on disk, which is the only honest way to start a real build after experimenting
with the file. The flag itself arrives in the next stage, along with the argument
parsing around it.
save_progress runs inside the loop, after every confirmation, and that
single placement is what makes the whole thing survivable. Close the laptop, lose
power, kill the terminal: the last step you pressed enter on is already on disk. Save
once at the end instead and the program is a paper list with extra steps.
The percentage is computed before the current step is added, so it reports work finished and never work in progress. Step 4 shows 17 percent because three steps were done when it was offered. Skipping is a separate answer from confirming for a physical reason: a part that has not arrived is not an optional step, it is a step you will come back to, and marking it done to make the number go up is how a missing standoff becomes a mystery two weeks later.
# labs/assembly_checklist.py — continued
def main() -> None:
parser = argparse.ArgumentParser(description="GLaDOS physical assembly checklist")
parser.add_argument("--status", action="store_true", help="print what is done, ready and blocked")
parser.add_argument("--reset", action="store_true", help="forget every confirmed step")
args = parser.parse_args()
steps = load_plan()
problems = plan_problems(steps)
if problems:
print(f"plan is not buildable — {len(problems)} problem(s):")
for problem in problems:
print(f" [x] {problem}")
sys.exit(1)
if args.reset:
PROGRESS_FILE.unlink(missing_ok=True)
print("progress cleared")
done = load_progress()
if args.status:
print(status(steps, done))
return
walk(steps, done)
if __name__ == "__main__":
main()
$ uv run python -m labs.assembly_checklist # third evening, multimeter still packed
[ 76%] Step 14/17: Wire I2S BCLK/LRCLK/DIN to GPIO 18/19/21 and the mic USB through the grommet.
tools: soldering iron, grommet
after: mount_audio, mount_pi — both ends have to exist before the wire between them
done? [enter=yes s=skip q=quit]: [ 82%] Step 15/17: Wire the MCP3008 to SPI4 on GPIO 4/5/6/7 and the divider taps to the rails.
tools: multimeter
after: mount_adc, mount_pi — both ends have to exist before the wire between them
done? [enter=yes s=skip q=quit]: skipped cable_adc
[skip] Step 16/17: power_rail is blocked by cable_adc
[skip] Step 17/17: final_check is blocked by power_rail
14/17 done
$ uv run python -m labs.assembly_checklist # multimeter found
[ 82%] Step 15/17: Wire the MCP3008 to SPI4 on GPIO 4/5/6/7 and the divider taps to the rails.
tools: multimeter
after: mount_adc, mount_pi — both ends have to exist before the wire between them
done? [enter=yes s=skip q=quit]: [ 88%] Step 16/17: Land the 5 V motion rail on the capacitor bank and tie every ground together.
tools: crimper, wire stripper
after: cable_arm, cable_eye, cable_adc — nothing gets a rail until its signal wire is dressed and anchored
done? [enter=yes s=skip q=quit]: [ 94%] Step 17/17: Power disconnected: tug every connector, look for a strand touching the Pi.
tools: flashlight
after: power_rail, cable_audio — it is the inspection of everything above it
done? [enter=yes s=skip q=quit]:
17/17 done — the body is assembled.
Skipping the divider wiring cost two more steps, and the program said so instead of
letting you find out with a screwdriver. That is the whole return on the
requires lists: the consequence of a skip propagates on its own, and the
two lines it prints name the missing prerequisite, not just the fact that something is
missing. The last step is gated behind every wiring step in the plan, which is
deliberate. A visual inspection that happens before the last connector goes in has
inspected a machine that no longer exists.
Why this works: three values that never touch each other
There are exactly three pieces of information here, and each one lives in its own place. The plan is data on disk, ordered and edited by you. The position is a set of ids, also on disk, written by the program and read by nothing else. The walk-through is presentation: it renders one from the other and holds nothing of its own. Every launch reconstructs what is left by filtering the plan against the position, so there is no third copy of the truth to fall out of date.
Resumability then stops being a feature and becomes a consequence. A program with no in-memory position cannot lose one. Run it twice in a row with no work done between and the second run offers exactly what the first offered, which is the same property that makes the plan file safe to write only when it is missing: both operations can be repeated without changing the answer. That is the whole reason to reach for a set of completed ids instead of a step counter. A counter says "you were at 12" and means nothing after you edit the plan; a set of ids survives insertions, deletions and reordering, because it names the work rather than counting it.
The dependency lists add one more property, and it is the one you feel on the bench. A
list is a total order: it claims that step 5 comes after step 4, which is usually a lie,
since fitting the mic array has nothing to do with the elbow. requires
declares a partial order, the real constraints and only those, and the file's order is
one valid reading of it. This is why the checker can call an edit wrong while still
letting you rearrange most of the plan freely, and why status can hand you
six independent things to do next. It also generalises past screwdrivers: any procedure
where a step forecloses access to an earlier one, from a migration that drops a column
to a deployment that revokes a key, is the same partial order with different tools.
There is one seam in all this, and it is better to know where it is than to pretend it
closed. The two files are joined by the id string alone. Rename mount_adc
to mount_battery_board after you have confirmed it and the step comes back
as undone, because the set holds the old name and the plan now offers a new one. That is
the right default for a checklist about physical objects: a step you cannot account for
should reappear, and the cost of reappearing is one glance under the side rail. It does
mean an id is a promise. Edit descriptions, tools and order as freely as you like; treat
the id as the thing you named the work, and leave it alone once a build is underway.
The obvious save_progress is one line shorter than the real one. A set of
strings looks like a list of strings, so hand it straight to json.dump
and write it where it belongs:
# labs/progress_naive.py
def save_progress(done: set[str], path: Path = PROGRESS_FILE) -> None:
with path.open("w") as f:
json.dump({"done": done}, f, indent=2) # BUG: a set is not a JSON type
$ uv run python -m labs.progress_naive
before: ['mount_shoulder', 'standoffs']
Traceback (most recent call last):
File "labs/progress_naive.py", line 16, in <module>
save_progress({"standoffs", "mount_shoulder", "mount_pi"})
File "labs/progress_naive.py", line 11, in save_progress
json.dump({"done": done}, f, indent=2) # BUG: a set is not a JSON type
...
File ".../json/encoder.py", line 180, in default
raise TypeError(f'Object of type {o.__class__.__name__} '
TypeError: Object of type set is not JSON serializable
when serializing dict item 'done'
The type error is the easy half, and JSON is right to refuse: the format has objects, arrays, strings, numbers, booleans and null, and no member with set semantics. The expensive half is what the disk looks like afterwards. Look at the file the run left behind, then start the checklist again:
$ wc -c glados/data/assembly_progress.json && cat glados/data/assembly_progress.json
12 glados/data/assembly_progress.json
{
"done":
$ uv run python -m labs.assembly_checklist --status
Traceback (most recent call last):
...
File "labs/assembly_checklist.py", line 17, in load_progress
return set(json.loads(path.read_text()).get("done", []))
...
json.decoder.JSONDecodeError: Expecting value: line 2 column 11 (char 12)
Twelve bytes: an opening brace, the key, and nothing after it. Reason backwards from
that number. open("w") truncates the file at the moment it opens, long
before anything is serialised, and json.dump streams its output as it
encodes, so the encoder had already written the key when it reached the value and
raised. The old contents were gone before the failure happened. Now the next launch
cannot even get to the checklist, because load_progress hits a file that
exists and does not parse, and an hour of confirmed steps is unrecoverable from a
program that never printed a warning.
One fix answers both halves. sorted(done) hands the encoder a list, and
writing to assembly_progress.tmp before os.replace keeps the
real file untouched until a complete document exists. Any failure now leaves a stray
temp file and the last good progress, which is the trade every durable writer makes:
the crash costs you the newest change, never the record.
Checkpoint, and a body whose limits just moved
- Given a fastener that a neighbouring part covers, I can write the constraint as a
requiresentry and say which of the two steps carries it. - I can explain why
plan_problemsneeds two passes over the plan and what a single pass would report falsely. - I can name the two jobs
sorted()does between a set in memory and a file on disk. - I can trace a twelve-byte progress file back to truncation on open, and say what
os.replacechanges about that. - I know why the checklist offers a skipped step again on the next run while a confirmed one never comes back.
- I can say what a set of completed ids survives that a step counter does not.
Exercise 1 — add the step your build needs. Put a 30 mm fan in the plan with its own prerequisite, deliberately in the wrong place first, and watch the checker refuse it.
The fan bolts through the rear lip of the Pi tray, so the tray has to be down
first. Insert it above mount_pi and run the checker:
{
"id": "mount_fan",
"desc": "Mount the 30 mm fan on the rear panel, 100 ohm inline for a quiet speed.",
"tools": ["M3 driver"],
"requires": ["mount_pi"],
"why": "it bolts through the tray's rear lip"
}
$ uv run python -m labs.assembly_plan
[x] step 'mount_fan' at position 3 requires 'mount_pi' at position 4
18 steps, 1 plan problem(s)
$ uv run python -m labs.assembly_plan # moved one line down
18 steps, 0 plan problem(s)
Notice what did not happen: your saved progress is untouched by an eighteenth step, because progress names ids and knows nothing about positions. The new id is simply not in the done set, so it turns up as ready the next time you run the checklist.
Exercise 2 — what to carry to the bench. Print every tool the remaining steps ask for, with how many steps ask for it, so one trip to the toolbox covers the evening.
# labs/tool_list.py
from collections import Counter
from labs.assembly_checklist import load_progress
from labs.assembly_plan import load_plan
if __name__ == "__main__":
steps = load_plan()
done = load_progress()
tally = Counter(tool for step in steps if step["id"] not in done for tool in step["tools"])
print(f"tools still needed for {len(steps) - len(done)} remaining steps:")
for tool, count in tally.most_common():
print(f" {count:2d}x {tool}")
$ uv run python -m labs.tool_list
tools still needed for 13 remaining steps:
3x M2.5 driver
2x M2 driver
2x soldering iron
2x zip ties
1x hot glue
1x M3 driver
1x heatshrink
1x grommet
1x multimeter
1x crimper
1x wire stripper
1x flashlight
The same filter as everywhere else, with a different renderer on the end. That is the payoff of keeping the plan, the position and the presentation apart: a new view of the build is six lines and touches neither of the first two.
Exercise 3 (stretch) — find what is holding up the most. For each unfinished step, count how many other steps are waiting on it, directly or through a chain, and rank them.
# labs/unblocks.py
from labs.assembly_checklist import load_progress
from labs.assembly_plan import load_plan
if __name__ == "__main__":
steps = load_plan()
done = load_progress()
waiting: dict[str, set[str]] = {}
for step in steps: # plan order is dependency order, so one pass does it
if step["id"] in done:
continue
blockers = set(need for need in step["requires"] if need not in done)
for other in list(blockers):
blockers |= waiting.get(other, set())
waiting[step["id"]] = blockers
holds: dict[str, int] = {}
for held, blockers in waiting.items():
for blocker in blockers:
holds[blocker] = holds.get(blocker, 0) + 1
ranked = sorted(waiting, key=lambda s: (-holds.get(s, 0), s))
for step_id in ranked[:6]:
print(f" {step_id:<20} holds up {holds.get(step_id, 0):2d} of the {len(waiting)} steps left")
$ uv run python -m labs.unblocks
mount_elbow holds up 4 of the 13 steps left
mount_eye_tilt holds up 4 of the 13 steps left
mount_adc holds up 3 of the 13 steps left
mount_led_ring holds up 3 of the 13 steps left
mount_pca holds up 3 of the 13 steps left
mount_wrist_gripper holds up 3 of the 13 steps left
The single pass works only because the plan is already checked: every prerequisite
appears earlier, so waiting holds a finished answer for it by the
time you need it. Run this on an unchecked plan and a forward reference gives you
a silently short count. Two of the six are themselves blocked, so a sharper
version filters the ranking down to steps that are ready right now, which is the
honest answer to "what should I pick up".
The last line of that final run is the end of a box of parts and the start of a machine. It also invalidated something. Every travel limit in the arm and eye code was measured on servos sitting loose on a bench, and those servos now live in printed mounts that push against them a few degrees earlier than a horn in free air. The next chapter drives each joint to the stop it actually has, inside the body, and writes what it measures into the one file every motion routine will trust from then on.