GLaDOS Vol 3 · Her Craft
ch 27 / 99
Chapter 27

Body simulator

Control code for a body that is still on the drawing board

Three volumes in, she hears, speaks, remembers, and holds a grudge for a configurable number of turns. All of it is software. The body comes later: servos to turn her head, an LED to show her state, a distance sensor to notice you walking up. None of that hardware is on your bench yet. So this chapter answers a planning question: what can you build today so that the day her first servo arrives is about wiring, not about debugging logic?

The obvious answer, wait for the hardware, has two costs. The first is the development loop: code that imports a GPIO library at the top of the file runs only on a machine that has one, so every change means copy to the board, run, read the error, copy again. The second cost is physical. A hobby servo has a limited travel; an out-of-range pulse can drive it against a stop until something gives. The allowed travel depends on the servo and its mount, not a universal 180-degree limit. On real hardware, a logic bug is a repair bill. On your desktop, the same bug is a wrong number in a dict.

Control logic reads and writes a device configuration, while a backend handles the device operation. The simulator below checks numerical guards behind that function signature; it does not establish a real driver’s behavior.

◆ Note — what a servo actually listens to

A hobby servo does not receive an angle. It receives a pulse repeated fifty times a second, and the width of that pulse encodes the target: roughly 1 millisecond means 0 degrees, roughly 2 milliseconds means 180. That scheme is called PWM, pulse-width modulation. The simulator deals in degrees on purpose: degrees are what the rest of her code means, and converting them to pulse widths is the future driver's job, at the hardware boundary where it belongs.

Devices as data, moves as functions

▣ Build · stage 1 — describe the body before you own it
# labs/body_sim.py
DEFAULT_DEVICES: dict = {
    "arm_servo": {"pin": 18, "type": "pwm", "enabled": False, "angle": 0},
    "status_led": {"pin": 17, "type": "gpio", "enabled": False, "state": "off"},
}

def simulate_move_servo(config: dict, device_name: str, angle: int) -> None:
    config[device_name]["angle"] = angle
    print(f"[SIM] {device_name} moved to {config[device_name]['angle']} degrees")

simulate_move_servo(DEFAULT_DEVICES, "arm_servo", 90)
$ uv run python labs/body_sim.py
[SIM] arm_servo moved to 90 degrees

Two decisions to notice. Each device is an entry in a dict, with its pin, its type, and its current state as plain fields: her body, as far as the logic is concerned, is data you can print, save, and diff. And the function takes the config as its first argument instead of reaching for a hardware library, so "moving" a servo means mutating a dict and announcing it. That print line is the entire difference between this backend and the real one.

▣ Build · stage 2 — clamp at the point of application
def simulate_move_servo(config: dict, device_name: str, angle: int) -> None:
    if device_name not in config:
        print(f"Device not found: {device_name}")
        return
    config[device_name]["angle"] = max(0, min(180, angle))
    print(f"[SIM] {device_name} moved to {config[device_name]['angle']} degrees")

simulate_move_servo(DEFAULT_DEVICES, "arm_servo", 250)
simulate_move_servo(DEFAULT_DEVICES, "arm_servo", -30)
simulate_move_servo(DEFAULT_DEVICES, "gripper", 45)
$ uv run python labs/body_sim.py
[SIM] arm_servo moved to 180 degrees
[SIM] arm_servo moved to 0 degrees
Device not found: gripper

max(0, min(180, angle)) pins any number into the range 0 to 180: for 250, the inner min yields 180 and the outer max keeps it; for -30, the min passes -30 through and the max raises it to 0. The clamp lives inside the function, not at the call sites, because call sites multiply: a typo, a bad sensor reading, or a confused language model can all produce an unsafe angle, and none of them can get past a limit enforced where the value is applied. The membership guard above it turns a would-be KeyError into a readable message, since asking for a device she does not have should be a fact, not a crash.

▣ Build · stage 3 — persist the simulated command
import copy
import json
from pathlib import Path

DEVICE_CONFIG = Path("configs/devices.json")

def load_device_config(path: Path) -> dict:
    if path.exists():
        with open(path) as f:
            return json.load(f)
    return copy.deepcopy(DEFAULT_DEVICES)

def save_device_config(config: dict, path: Path) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    with open(path, "w") as f:
        json.dump(config, f, indent=2)

config = load_device_config(DEVICE_CONFIG)
simulate_move_servo(config, "arm_servo", 135)
save_device_config(config, DEVICE_CONFIG)
print(f"angle on disk: {json.loads(DEVICE_CONFIG.read_text())['arm_servo']['angle']}")
$ uv run python labs/body_sim.py
[SIM] arm_servo moved to 135 degrees
angle on disk: 135

Chapter 22 taught the load-or-defaults pattern for her preferences; here it keeps the simulator's last commanded angle. The input is the requested angle; the clamp turns it into the command stored in angle. In this simulator, assigning that number is the whole move. On hardware, a saved command says nothing about a stalled joint or an arm somebody moved while power was off. Physical position remains unknown until a suitable measurement or supervised startup establishes it. No position feedback reaches this program.

Neither zero nor the saved command is a safe automatic restart target. Keep motion disabled while an operator checks clearance and supports the mechanism as needed; use the selected hardware's reviewed startup or homing procedure before enabling ordinary moves. An open-loop hobby servo needs supervision, and even its first pulse can cause movement. Persisted state helps diagnostics; it does not replace that procedure or an independent stop. One software detail: the fallback is copy.deepcopy, not .copy(), because a shallow copy shares the nested per-device dicts, and mutating the "copy" would silently rewrite your defaults. Exercise 3 makes that bug visible.

▣ Build · stage 4 — the module her body will inherit
# labs/body_sim.py — full file
import copy
import json
import random
from pathlib import Path

DEVICE_CONFIG = Path("configs/devices.json")

DEFAULT_DEVICES: dict = {
    "arm_servo": {"pin": 18, "type": "pwm", "enabled": False, "angle": 0},
    "status_led": {"pin": 17, "type": "gpio", "enabled": False, "state": "off"},
}

def load_device_config(path: Path) -> dict:
    if path.exists():
        with open(path) as f:
            return json.load(f)
    return copy.deepcopy(DEFAULT_DEVICES)

def save_device_config(config: dict, path: Path) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    with open(path, "w") as f:
        json.dump(config, f, indent=2)

def simulate_move_servo(config: dict, device_name: str, angle: int) -> None:
    if device_name not in config:
        print(f"Device not found: {device_name}")
        return
    config[device_name]["angle"] = max(0, min(180, angle))
    print(f"[SIM] {device_name} moved to {config[device_name]['angle']} degrees")

def simulate_read_distance() -> float:
    return round(random.uniform(5.0, 50.0), 1)

def main() -> None:
    config = load_device_config(DEVICE_CONFIG)
    simulate_move_servo(config, "arm_servo", 90)
    print(f"[SIM] distance sensor: {simulate_read_distance()} cm")
    save_device_config(config, DEVICE_CONFIG)
    print(f"State saved to {DEVICE_CONFIG}")

if __name__ == "__main__":
    main()
$ uv run python labs/body_sim.py
[SIM] arm_servo moved to 90 degrees
[SIM] distance sensor: 23.7 cm
State saved to configs/devices.json

The distance figure is random by design, so yours will differ on every run. simulate_read_distance is the sensor side of the same idea: code that reacts to "someone is closer than 30 cm" can be written and tested against plausible fake readings long before an ultrasonic module exists to produce real ones.

Why this works: intent above, mechanism below

simulate_move_servo(config, "arm_servo", 90) states an intent: set this named device to this angle. It says nothing about how the bits reach a motor, and that silence is the entire design. The simulator's mechanism is a print statement; the future driver's mechanism is a PWM duty cycle on pin 18. Both accept the same arguments, mutate the same config dict, and honor the same clamp. Everything above the call (the mood system deciding she should turn away from you, the event bus carrying the command) stays identical whichever backend is underneath, so choosing between them collapses to a single import at startup.

This is a hardware abstraction layer, and you have been living on top of them all along: your operating system does exactly this so that the same open() call works on any brand of disk. Two properties do the work here. The config dict is the shared contract, one source of truth about pins and state that both backends read and write. And the safety rule travels with the action: the clamp sits inside the move function, so swapping backends cannot bypass it and no caller has to remember it. Rules enforced by position beat rules enforced by discipline.

The simulator checks ranges, state changes and persistence. It cannot test torque, wiring or a supply under load; the assembled body needs supervised tests and its own startup procedure.

⚠ Worked failure — the module that would not even import

The tempting shortcut is to put the real driver's import at the top of the file now, so everything is ready for the board later:

import RPi.GPIO as GPIO   # BUG: top-level import of a board-only library

GPIO.setmode(GPIO.BCM)
GPIO.setup(18, GPIO.OUT)
$ uv run python labs/body_sim.py
Traceback (most recent call last):
  File "/home/you/GladOS/labs/body_sim.py", line 2, in <module>
    import RPi.GPIO as GPIO
ModuleNotFoundError: No module named 'RPi'

Read the traceback's location line: the failure is in <module>, at import time, before any function has run. That placement is the real damage. It does not matter that simulate_move_servo never touches GPIO; with the import at module scope, you cannot even import body_sim on the desktop to test the clamp, so one line written "for later" disables every test you can run today. The fix is positional: the simulator imports no hardware library at all, and the future driver will import its own lazily, inside the function, behind a try/except that falls back to simulation. The module then loads anywhere, and where it runs decides what it drives.

Checkpoint, before the metal

✓ Checkpoint — what you can now do
  • I can trace max(0, min(180, angle)) by hand for 250, -30, and 90, and say why the clamp lives inside the move function instead of at any call site.
  • I can explain what the device config dict contributes: one source of truth about pins and state that both backends read and write.
  • I know a top-level board-only import fails at import time, not call time, and exactly what that blocks on a desktop.
  • I can distinguish the requested angle, the saved command and an unknown physical position, and explain why restart needs supervised preparation.
  • I can list what the simulator proves (ranges, guards, state) and what only the physical build can (torque, noise, power).
⚡ Exercises — try first, then reveal
Exercise 1 — give the LED a verb. Write simulate_toggle_led(config, device_name): flip the LED's state between "on" and "off", with the same missing-device guard, and print a [SIM] line. Call it twice and predict both outputs first.

Read the current state, compute the opposite ("on" if current == "off" else "off"), write it back, print it. Two calls on status_led print toggled to on then toggled to off: a toggle's output depends on state, your first control function where calling order matters. Save the config and check the file; the LED's state should round-trip exactly like the angle did.

Exercise 2 — sweep, do not snap. Write simulate_sweep(config, device_name, start, end, steps) that moves the servo in increments, printing each intermediate angle with a short time.sleep between steps. Route every step through simulate_move_servo. Why that routing rule?

Each step's angle is start + round((end - start) * i / steps) for i from 0 through steps. Routing through the move function means every intermediate value hits the clamp and the guard, so a sweep to 400 flattens harmlessly at 180. On the future hardware this function becomes the difference between a head that turns and a head that snaps: small steps with pauses are how a position-commanded servo moves smoothly.

Exercise 3 — corrupt your defaults on purpose. Change load_device_config to return DEFAULT_DEVICES.copy() instead of the deepcopy, delete configs/devices.json, then move the servo and print DEFAULT_DEVICES. Explain what you see before you fix it.

DEFAULT_DEVICES["arm_servo"]["angle"] now reads 135, or wherever you moved it: the shallow copy duplicated the outer dict but shares the nested per-device dicts, so the "copy" and the defaults are the same objects one level down. Your fallback drifts with use, and every future fresh start inherits a stale pose. copy.deepcopy duplicates the whole tree. The general rule: a shallow copy protects one level, and nested state needs a deep one.

Her body now exists as a contract: devices as data, moves as clamped functions, state that survives a restart. But the system around that contract keeps growing, and when a pipeline of ears, mind, voice, and now simulated motion misbehaves, "something is slow" stops being useful information. Next chapter replaces guessing with measurement: a structured logger and a decorator that stamps every stage with its duration, and never swallows an error on the way through.