Hardware Calibration
Two numbers per servo, and neither one is on the bag
Her body is assembled. Every servo is bolted into the part it drives, every wire goes where the pin map says it goes, and the numbers that tell those servos how far they may travel are still guesses typed into source files. The eye carries twenty five degrees of pan and twenty of tilt because someone held a protractor against the gimbal and rounded. The arm carries four bands read off the printed parts. Chapter 64 drove a servo from a 0.5 millisecond pulse to a 2.5 millisecond one and got a hum at both ends, on a unit that had no business reaching either.
Nothing about those numbers is knowable in advance. A hobby servo's packaging promises that a 1.0 millisecond pulse means one end of travel and 2.0 means the other, and the promise is made about a design, not about the unit in your hand. Manufacturing tolerance moves both ends. The printed mount moves them again, usually inward, because a cradle hits a yoke long before the servo's own gear stops arrive. So there are exactly two numbers that matter per servo, the pulse width at each real physical limit, and both of them are properties of one servo in one mount on one day.
Chapter 42 set the discipline for this: drive the part, measure what it does, write the
reading down next to the specification it was checked against. What is new here is where
the reading goes. A measurement pasted into eye_control.py, a second copy in
the arm's config and a third in a test script is the same fact in three places, and
three places disagree the first time a servo is replaced. So the rule for this chapter:
the two pulse widths that mark a servo's real ends of travel are measurements of
a physical object, so they belong in a file the code reads at startup, and every motion
script gets its limits from that file instead of from a constant somebody typed.
A Raspberry Pi 4B with her body fully assembled, the eye servos on GPIO 12 and 13, the bench sweep rig still wired to GPIO 17, and the arm's four joints on the PCA9685 that hangs off I2C on GPIO 2 and 3. Two instruments are in play and neither is a programming tool. A paper protractor taped under the moving part reads the travel in degrees. A multimeter in series with the servo supply, or the current display on a bench supply, reads the draw: a joint sitting quietly under 0.1 A is holding, and a joint that jumps past half an amp with nothing moving is stalled against something. Every number below came off one build. Yours will differ, and the whole point of the chapter is that they are supposed to.
The stepper, before any pin is claimed
# labs/calibrate_math.py
COARSE_US = 50 # a press you can see the horn answer
FINE_US = 5 # a press you dial the last hair in with
PW_MIN_US = 500 # nothing outside these two is ever an anchor
PW_MAX_US = 2500
STEPS = {"+": COARSE_US, "-": -COARSE_US, "f+": FINE_US, "f-": -FINE_US}
def step_pulse(pulse_us: int, command: str) -> int:
"""Apply one keypress to a pulse width, clamped to a range no servo is hurt by."""
if command not in STEPS:
raise ValueError(f"unknown command: {command!r}")
return max(PW_MIN_US, min(PW_MAX_US, pulse_us + STEPS[command]))
if __name__ == "__main__":
pulse = 1000
for command in ("+", "+", "f+", "f-", "f-"):
pulse = step_pulse(pulse, command)
print(f"{command:>2} -> {pulse:5d} us ({pulse / 1000:.3f} ms)")
floor = 1000
for _ in range(20):
floor = step_pulse(floor, "-")
print(f"twenty coarse steps down from 1000 us -> {floor} us")
$ uv run python -m labs.calibrate_math
+ -> 1050 us (1.050 ms)
+ -> 1100 us (1.100 ms)
f+ -> 1105 us (1.105 ms)
f- -> 1100 us (1.100 ms)
f- -> 1095 us (1.095 ms)
twenty coarse steps down from 1000 us -> 500 us
The interesting logic in a calibration tool is the clamp, and the clamp is the one part that never needs a servo. Written as a function from a number and a command to a number, it runs on a laptop, and you can watch the floor hold at 500 after twenty presses that would otherwise have walked the anchor down to zero. Those bounds are not the answer to anything; they are the widest range this class of servo tolerates without damage, and the session that follows exists to come well inside them.
Microseconds as integers, not seconds as floats, for two reasons. The board in chapter 69 already speaks in microseconds, so one vocabulary covers the whole body. And the step sizes mean something in that unit: chapter 64 put this servo family's deadband near ten microseconds of pulse, the window the control circuit treats as no error at all, so a single fine press may produce no motion and two of them always will. A coarse press of 50 microseconds is roughly five and a half degrees of shaft, which is a movement you can see from across the bench.
# labs/calibrate_config.py
import json
import sys
from pathlib import Path
HARDWARE_PATH = Path("configs/hardware.json")
def load_servo_pins(path: Path = HARDWARE_PATH) -> dict[str, int]:
"""Servo name -> BCM pin, straight out of the map chapter 71 validated."""
if not path.exists():
print(f"no {path}: build and check the pin map first, then wire what it says.")
sys.exit(1)
config = json.loads(path.read_text())
return {name: servo["bcm_pin"] for name, servo in config.get("servos", {}).items()}
if __name__ == "__main__":
pins = load_servo_pins()
print(f"{len(pins)} servos to calibrate, pins from {HARDWARE_PATH}")
for name, pin in pins.items():
print(f" {name:<11}GPIO {pin}")
$ uv run python -m labs.calibrate_config
3 servos to calibrate, pins from configs/hardware.json
eye_pan GPIO 12
eye_tilt GPIO 13
bench_test GPIO 17
A calibration tool that knows its own pin numbers is a fourth place for a wiring fact
to live. This one asks, and it asks the file that was already checked for collisions,
so moving the tilt servo to a different header pin is one edit and the calibrator
follows. The .get("servos", {}) is there so a map with no header servos at
all yields an empty dictionary instead of a KeyError: the arm's joints
hang off a driver board and carry channel numbers, not GPIO pins, and a machine wired
that way should get a polite "nothing to do" from this tool.
One end at a time, with the horn in front of you
# labs/calibrate.py
from gpiozero import Servo
from labs.calibrate_config import load_servo_pins
from labs.calibrate_math import step_pulse
DEFAULT_MIN_US = 1000
DEFAULT_MAX_US = 2000
MIN_SPAN_US = 200 # two anchors closer than this describe no useful travel
PROMPT = " [{pulse:4d} us] +/-/f+/f-/done/skip: "
def drive(pin: int, low_us: int, high_us: int, end: str) -> Servo:
"""Build a servo with these two anchors and send it to one of them."""
servo = Servo(pin, min_pulse_width=low_us / 1e6, max_pulse_width=high_us / 1e6)
servo.value = -1.0 if end == "min" else 1.0
return servo
def find_end(name: str, pin: int, end: str) -> int:
"""Move one anchor until the part just reaches the stop that anchor belongs to."""
pulse = DEFAULT_MIN_US if end == "min" else DEFAULT_MAX_US
other = DEFAULT_MAX_US if end == "min" else DEFAULT_MIN_US
servo = drive(pin, min(pulse, other), max(pulse, other), end)
print(f" {name} driven to its {end} end; nudge until it just touches the stop.")
try:
while True:
command = input(PROMPT.format(pulse=pulse)).strip().lower()
if command == "done":
return pulse
if command == "skip":
return DEFAULT_MIN_US if end == "min" else DEFAULT_MAX_US
try:
candidate = step_pulse(pulse, command)
except ValueError as exc:
print(f" {exc}")
continue
if abs(candidate - other) < MIN_SPAN_US:
print(f" refused: that leaves under {MIN_SPAN_US} us between the anchors.")
continue
pulse = candidate
servo.close()
servo = drive(pin, min(pulse, other), max(pulse, other), end)
finally:
servo.close()
# labs/calibrate_one.py — one servo, one end, for getting the feel of it
import sys
from labs.calibrate import find_end
from labs.calibrate_config import load_servo_pins
if __name__ == "__main__":
name, end = sys.argv[1], sys.argv[2]
print(f"{name} {end} anchor: {find_end(name, load_servo_pins()[name], end)} us")
$ uv run python -m labs.calibrate_one eye_pan min # on the bench — yours will vary
eye_pan driven to its min end; nudge until it just touches the stop.
[1000 us] +/-/f+/f-/done/skip: +
[1050 us] +/-/f+/f-/done/skip: +
[1100 us] +/-/f+/f-/done/skip: +
[1150 us] +/-/f+/f-/done/skip: +
[1200 us] +/-/f+/f-/done/skip: +
[1250 us] +/-/f+/f-/done/skip: done
eye_pan min anchor: 1250 us
Read the first line of that session as a diagnosis. At the default anchor of 1000
microseconds the servo is already hard against the printed yoke, buzzing, and the
supply reads 0.58 A with nothing moving. Each press walks the anchor up, and the horn
follows because the servo is still commanded to value = -1.0, which is
whatever the current minimum happens to be. Somewhere around 1250 the buzz stops and
the cradle sits against the yoke without pressing on it. That is the number, and no
calculation could have produced it.
The rebuild in the middle of the loop is the part that looks wasteful and is not
optional. gpiozero fixes the pulse mapping when a Servo is constructed,
and exposes min_pulse_width as a read-only property, so there is no way to
slide an anchor on a live object. Previewing a new anchor means building a new servo,
which is a general shape in device libraries: a parameter that the driver turned into
precomputed state at construction is a parameter you replace the object to change.
Closing the old one first matters as much as building the new one, for a reason the
failure below makes loud.
# labs/calibrate.py — continued
import json
from datetime import datetime
from pathlib import Path
from labs.calibrate_config import HARDWARE_PATH
CAL_PATH = Path("configs/calibration.json")
DRAFT_PATH = Path("configs/calibration.draft.json")
def calibrate_servo(name: str, pin: int) -> dict:
print(f"\n--- {name} on GPIO {pin} (BCM) ---")
min_us = find_end(name, pin, "min")
max_us = find_end(name, pin, "max")
travel = float(input(" protractor, total travel between those two ends, deg: "))
return {
"bcm_pin": pin,
"min_pulse_us": min_us,
"max_pulse_us": max_us,
"travel_deg": travel,
"us_per_deg": round((max_us - min_us) / travel, 2),
"measured_at": datetime.now().isoformat(timespec="seconds"),
}
def default_entry(pin: int) -> dict:
return {"bcm_pin": pin, "min_pulse_us": DEFAULT_MIN_US, "max_pulse_us": DEFAULT_MAX_US,
"travel_deg": None, "us_per_deg": None,
"note": "gpiozero defaults, not calibrated"}
def save(records: dict, path: Path) -> None:
"""Write through a temporary file so a reader never sees half a config."""
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(".tmp")
tmp.write_text(json.dumps(records, indent=2) + "\n")
tmp.replace(path)
def main() -> None:
pins = load_servo_pins()
records = json.loads(CAL_PATH.read_text()) if CAL_PATH.exists() else {}
print(f"{len(pins)} servos, pins from {HARDWARE_PATH}; "
f"{len(records)} already in {CAL_PATH}")
for name, pin in pins.items():
if input(f"calibrate '{name}' on GPIO {pin}? [y/N]: ").strip().lower() == "y":
records[name] = calibrate_servo(name, pin)
save(records, DRAFT_PATH)
print(f" draft saved: {DRAFT_PATH} ({len(records)} servos)")
elif name in records:
kept = records[name]
print(f" {name} kept: {kept['min_pulse_us']}-{kept['max_pulse_us']} us,"
f" measured {kept.get('measured_at', 'never')}")
else:
records[name] = default_entry(pin)
print(f" {name} has no entry; recording the gpiozero defaults, uncalibrated.")
save(records, CAL_PATH)
print(f"\nwrote {CAL_PATH}")
if __name__ == "__main__":
main()
$ uv run python -m labs.calibrate # on the bench — yours will vary
3 servos, pins from configs/hardware.json; 1 already in configs/calibration.json
calibrate 'eye_pan' on GPIO 12? [y/N]: n
eye_pan kept: 1250-1700 us, measured 2026-08-21T20:14:07
calibrate 'eye_tilt' on GPIO 13? [y/N]: y
--- eye_tilt on GPIO 13 (BCM) ---
eye_tilt driven to its min end; nudge until it just touches the stop.
[1000 us] +/-/f+/f-/done/skip: +
[1050 us] +/-/f+/f-/done/skip: +
[1100 us] +/-/f+/f-/done/skip: +
[1150 us] +/-/f+/f-/done/skip: +
[1200 us] +/-/f+/f-/done/skip: +
[1250 us] +/-/f+/f-/done/skip: +
[1300 us] +/-/f+/f-/done/skip: +
[1350 us] +/-/f+/f-/done/skip: done
eye_tilt driven to its max end; nudge until it just touches the stop.
[2000 us] +/-/f+/f-/done/skip: -
[1950 us] +/-/f+/f-/done/skip: -
[1900 us] +/-/f+/f-/done/skip: -
[1850 us] +/-/f+/f-/done/skip: -
[1800 us] +/-/f+/f-/done/skip: -
[1750 us] +/-/f+/f-/done/skip: -
[1700 us] +/-/f+/f-/done/skip: -
[1650 us] +/-/f+/f-/done/skip: done
protractor, total travel between those two ends, deg: 34
draft saved: configs/calibration.draft.json (2 servos)
calibrate 'bench_test' on GPIO 17? [y/N]: n
bench_test has no entry; recording the gpiozero defaults, uncalibrated.
wrote configs/calibration.json
{
"eye_pan": {
"bcm_pin": 12,
"min_pulse_us": 1250,
"max_pulse_us": 1700,
"travel_deg": 51.0,
"us_per_deg": 8.82,
"measured_at": "2026-08-21T20:14:07"
},
"eye_tilt": {
"bcm_pin": 13,
"min_pulse_us": 1350,
"max_pulse_us": 1650,
"travel_deg": 34.0,
"us_per_deg": 8.82,
"measured_at": "2026-08-22T09:31:55"
},
"bench_test": {
"bcm_pin": 17,
"min_pulse_us": 1000,
"max_pulse_us": 2000,
"travel_deg": null,
"us_per_deg": null,
"note": "gpiozero defaults, not calibrated"
}
}
The session starts by loading what is already on disk, so answering no keeps a measurement instead of overwriting it with a default. Calibration is not something you sit down and finish; it happens a servo at a time, on the evenings you have the body open, and a tool that demands all three in one sitting gets run once and then avoided. The draft written after each servo is the same idea at a smaller scale, and both writes go through a temporary file that is renamed into place, so a tool interrupted halfway leaves either the old file or the new one.
us_per_deg is a derived field, and it is the cheapest error check in the
file. Both eye servos land on 8.82 microseconds per degree, and the servo measured free
on the bench in chapter 42 worked out to 8.81, because this is one servo family with
one gear ratio. A fourth entry reporting 4.4 or 17.6 is not a remarkable servo; it is a
protractor read from the wrong side of centre, or a travel figure entered as half the
sweep. The uncalibrated entry carries nulls and a note instead of plausible-looking
defaults, so nothing downstream can mistake a guess for a measurement.
The file every moving thing reads at startup
# labs/calibrated.py
import json
from pathlib import Path
from gpiozero import Servo
from labs.calibrate_config import load_servo_pins
CAL_PATH = Path("configs/calibration.json")
def load_calibration(path: Path = CAL_PATH) -> dict[str, dict]:
if not path.exists():
raise FileNotFoundError(f"{path}: calibrate before running anything that moves")
return json.loads(path.read_text())
def make_servo(name: str, pins: dict[str, int], cal: dict[str, dict]) -> Servo:
"""A servo whose -1.0 and +1.0 land on the stops somebody measured."""
entry = cal[name]
return Servo(pins[name],
min_pulse_width=entry["min_pulse_us"] / 1e6,
max_pulse_width=entry["max_pulse_us"] / 1e6)
def axis_limit_deg(entry: dict) -> float:
"""Half the measured travel: the eye's angles are signed from centre."""
return entry["travel_deg"] / 2.0
DECLARED = {"eye_pan": 25.0, "eye_tilt": 20.0} # the limits set by hand in chapter 65
def audit(cal: dict[str, dict], declared: dict[str, float]) -> None:
print(f"{'servo':<10}{'band (us)':<14}{'travel':>7}{'measured':>10}{'declared':>10} verdict")
for name, limit in declared.items():
entry = cal[name]
band = f"{entry['min_pulse_us']}-{entry['max_pulse_us']}"
measured = axis_limit_deg(entry)
verdict = ("inside the stop" if measured >= limit
else f"{limit - measured:.1f} deg past the stop")
print(f"{name:<10}{band:<14}{entry['travel_deg']:>7.1f}"
f"{measured:>10.1f}{limit:>10.1f} {verdict}")
if __name__ == "__main__":
audit(load_calibration(), DECLARED)
$ uv run python -m labs.calibrated
servo band (us) travel measured declared verdict
eye_pan 1250-1700 51.0 25.5 25.0 inside the stop
eye_tilt 1350-1650 34.0 17.0 20.0 3.0 deg past the stop
# labs/eye_control.py — the two class constants become constructor arguments
class EyeController:
EASE_STEPS = 30
EASE_DELAY = 0.012
def __init__(self, pan: Servo, tilt: Servo,
pan_limit: float, tilt_limit: float) -> None:
self.pan, self.tilt = pan, tilt
self.pan_limit, self.tilt_limit = pan_limit, tilt_limit
self._pan_deg = 0.0
self._tilt_deg = 0.0
self._apply(0.0, 0.0)
def _apply(self, pan_deg: float, tilt_deg: float) -> None:
"""Still the only place in the program that writes to a servo."""
pan_deg = max(-self.pan_limit, min(self.pan_limit, pan_deg))
tilt_deg = max(-self.tilt_limit, min(self.tilt_limit, tilt_deg))
self.pan.value = pan_deg / self.pan_limit
self.tilt.value = tilt_deg / self.tilt_limit
self._pan_deg, self._tilt_deg = pan_deg, tilt_deg
def calibrated_eye() -> EyeController:
"""Every number in the eye's motion path, sourced from the two config files."""
pins, cal = load_servo_pins(), load_calibration()
return EyeController(pan=make_servo("eye_pan", pins, cal),
tilt=make_servo("eye_tilt", pins, cal),
pan_limit=axis_limit_deg(cal["eye_pan"]),
tilt_limit=axis_limit_deg(cal["eye_tilt"]))
$ uv run python -m labs.eye_calibrated # on the bench — yours will vary
eye built from configs/calibration.json: pan +-25.5 deg, tilt +-17.0 deg
look_at(24, -12) pan= +24.0 tilt= -12.0
look_at(40, -25) pan= +25.5 tilt= -17.0 both clamped at a measured stop
blink() pan= +25.5 tilt= -17.0 shutter to -17.0, quiet at 0.08 A
The audit is the payoff, and it does not flatter the guesses. Pan was set by hand at 25
degrees against a real 25.5, so half a degree of travel was going unused and nothing
was ever in danger. Tilt was set at 20 against a real 17. Three degrees does not sound
like much until you notice what drives to the tilt limit: the blink drops the shutter
to -tilt_limit and holds it there, which under the old constant meant
three degrees of press against the yoke, several times a minute, for as long as she is
awake. The supply reading in the last line is the confirmation. A blink that ends at
0.08 A is a blink that ends against nothing.
Promoting two class constants to constructor arguments is a small edit with a specific intent. A class constant says "this is true of every eye"; a constructor argument says "this is true of the eye you handed me". The clamp, the conversion and the single writing method are all exactly as chapter 65 left them, because the design was never the problem. The numbers were.
Why this works: two anchors and a straight line between them
gpiozero's servo interface holds two pulse widths and interpolates linearly between them. A value of -1.0 emits the minimum anchor, +1.0 emits the maximum, 0.0 emits the midpoint, and everything in between is a proportion of the span. That is the entire mechanism, and it means calibration is not a correction applied to a command. It is the act of choosing where the ends of the number line sit. Once the two anchors are the real physical stops, a value of -1.0 is by construction a reachable position, and so is every value between. The library then cannot be asked for a position the mount will not allow, because the library has no way to express one.
Two consequences follow that are easy to miss. The first is that a value of 0.0 is the midpoint of the two anchors and nothing more. On the tilt axis the midpoint sits at 1500 microseconds and the eye does look straight ahead there, but only because that mount happens to be symmetric; pan's midpoint is 1475, and an asymmetric bracket can put the true straight-ahead pulse tens of microseconds away from it. Where the mount is lopsided you record the centre as a third measurement instead of assuming it. The second is that every degree figure in the code is downstream of the anchors. Change an anchor without changing the travel figure it was measured with, and the eye keeps obeying, keeps reporting, and keeps being wrong by a fixed proportion.
The arm gets the same treatment with different plumbing. Its joints hang off the driver board, which takes microseconds directly, so there is no gpiozero object to rebuild; you drive a joint to each stop, read the protractor, and convert the reachable travel into the degree band the joint's config carries. What the config looks like, how it is frozen, and the single method that clamps and writes all stay exactly as chapter 69 built them. The bands stop being numbers read off a drawing and start being numbers read off the assembled machine, and no code above the writing method notices the difference. That is the property a measured contract buys: the file absorbs the change, and the software that consumes it holds still.
The obvious way to swap anchors is the one chapter 64 already taught: stop the pulse
train, then build the replacement. detach() is the method that stops
pulses, so it goes in the loop, and the tool looks finished:
pulse = step_pulse(pulse, command)
servo.detach() # stops the pulses, and that is all it does
servo = drive(pin, min(pulse, other), max(pulse, other), end)
$ uv run python -m labs.calibrate_one eye_pan min
eye_pan driven to its min end; nudge until it just touches the stop.
[1000 us] +/-/f+/f-/done/skip: +
Traceback (most recent call last):
File "/home/pi/glados/labs/calibrate_one.py", line 9, in <module>
print(f"{name} {end} anchor: {find_end(name, load_servo_pins()[name], end)} us")
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/pi/glados/labs/calibrate.py", line 43, in find_end
servo = drive(pin, min(pulse, other), max(pulse, other), end)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/pi/glados/labs/calibrate.py", line 15, in drive
servo = Servo(pin, min_pulse_width=low_us / 1e6, max_pulse_width=high_us / 1e6)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
gpiozero.exc.GPIOPinInUse: pin GPIO12 is already in use by <gpiozero.Servo object on pin GPIO12, active_high=True, is_active=False>
The first press is enough. Nothing was mistyped, the pin number is right, and the
servo it names is the one this program built four lines earlier. Read the exception
literally: the pin is in use by a Servo, and the only servo in the process
is the one just detached. So detaching did not do what the name suggested.
Two different resources are in play and they have separate lifetimes. The pulse train
is one: detach() stops it, the servo goes limp, and that is exactly what
chapter 64 wanted at the end of a sweep. The pin reservation is the other. Every
gpiozero device registers itself with the pin factory when it is constructed, and the
factory refuses a second claim on the same pin, which is the same protection chapter 71
built by hand across subsystems and which works only inside one process. A detached
servo still holds its reservation, so the constructor is right to refuse. The fix is
close(), which stops the pulses and releases the pin, and which is safe to
call twice, so the finally clause that closes on the way out costs nothing
when the loop already closed. The general form of the lesson: releasing a device's
output is not the same operation as releasing the device.
Checkpoint, and the run that signs off the body
- I can explain what a servo's two calibrated anchors are anchors of, and why a value of -1.0 means something different after calibration than before it.
- I can walk a servo to a physical stop with coarse and fine presses, and say what the supply current tells me about whether I have gone one press too far.
- I know why the tool builds a new
Servofor every keypress, and whyclose()is required wheredetach()looked sufficient. - I can read a calibration entry and decide from
us_per_degalone whether the protractor reading behind it is believable. - I can trace the eye's tilt limit from a measured pulse band to a blink that no longer presses on the yoke.
- I can say what a midpoint pulse is, and why it is not automatically the direction the eye is looking when it looks straight ahead.
Exercise 1 — catch a bad protractor reading. Compare every calibrated entry's microseconds per degree against the figure this servo family actually produces, and flag anything that is not close.
Suppose bench_test gets calibrated at 760 to 2240 microseconds, but the
travel is entered as 84 degrees because it was read from centre instead of end to
end:
from labs.calibrated import load_calibration
CLASS_US_PER_DEG = 8.8 # this family of nine-gram servos, from the bench
for name, entry in load_calibration().items():
if entry.get("us_per_deg") is None:
print(f"{name:<11}uncalibrated")
continue
ratio = entry["us_per_deg"] / CLASS_US_PER_DEG
verdict = "plausible" if 0.8 <= ratio <= 1.25 else "re-read the protractor"
print(f"{name:<11}{entry['us_per_deg']:5.2f} us/deg {ratio:4.2f}x class {verdict}")
$ uv run python -m labs.calibration_check
eye_pan 8.82 us/deg 1.00x class plausible
eye_tilt 8.82 us/deg 1.00x class plausible
bench_test 17.62 us/deg 2.00x class re-read the protractor
A factor of exactly two is the signature of a half-sweep measured as a full one, and it is the mistake this check exists for. Nothing else in the file would have caught it: the pulse anchors are correct, and only the degrees are wrong.
Exercise 2 — measure the drift after a screw is retightened. Calibrate a servo, disturb the mount, calibrate again, and report the change in both anchors and in total travel.
def drift(old: dict, new: dict) -> str:
d_min = new["min_pulse_us"] - old["min_pulse_us"]
d_max = new["max_pulse_us"] - old["max_pulse_us"]
d_deg = (d_max - d_min) / old["us_per_deg"]
return f"min {d_min:+4d} us max {d_max:+4d} us travel {d_deg:+5.1f} deg"
old = {"min_pulse_us": 1250, "max_pulse_us": 1700, "us_per_deg": 8.82}
new = {"min_pulse_us": 1285, "max_pulse_us": 1690, "us_per_deg": 8.82}
print("eye_pan " + drift(old, new))
$ uv run python -m labs.calibration_drift
eye_pan min +35 us max -10 us travel -5.1 deg
Five degrees of gaze disappeared because a bracket screw was tightened a quarter turn. Keep the old entry alongside the new one and the file starts answering a second question: not only where the stops are, but whether they are moving.
Exercise 3 — find the value that points a lopsided mount straight ahead. Given the two anchors and the pulse width at which the eye visibly looks forward, compute the gpiozero value that lands there.
def centre_value(entry: dict, centre_us: int) -> tuple[float, float]:
"""Invert the linear map: which value emits this pulse, and how far off midpoint is it?"""
mid = (entry["min_pulse_us"] + entry["max_pulse_us"]) / 2
half_span = (entry["max_pulse_us"] - entry["min_pulse_us"]) / 2
return (centre_us - mid) / half_span, (centre_us - mid) / entry["us_per_deg"]
pan = {"min_pulse_us": 1250, "max_pulse_us": 1700, "us_per_deg": 8.82}
for pulse in (1520, 1475):
value, offset = centre_value(pan, pulse)
print(f"straight ahead at {pulse} us -> value {value:+.3f} ({offset:+.1f} deg off midpoint)")
$ uv run python -m labs.calibration_centre
straight ahead at 1520 us -> value +0.200 (+5.1 deg off midpoint)
straight ahead at 1475 us -> value +0.000 (+0.0 deg off midpoint)
A non-zero answer means the mechanical centre is not the midpoint, and the honest repair is to store that pulse width as a third field and offset every command by it, not to fudge one of the anchors until the arithmetic comes out even. An anchor describes a stop; moving it to fix a centring error throws away travel at the end you moved.
Every moving part of her body now takes its limits from a file that records what somebody measured, on a date, with an instrument. The pieces have also been tested one at a time and only one at a time: an eye that behaves alone, an arm that homes alone, a speaker checked while nothing else was drawing current. Next comes the run that exercises all six subsystems in one sitting, isolates each check so a single failure cannot hide the other five, and ends with a dated verdict on whether this hardware is ready to carry the software stack.