Build: The Timer Firmware
Purpose
Section titled “Purpose”To write the firmware that makes the hardware an instrument, and to establish the one property that distinguishes an instrument from a device: that every exposure it makes is recorded, with the interval it achieved beside the interval it was asked for.
Three things come out of this session. Firmware in full, published and runnable, implementing the f-stop arithmetic of the lesson two pages back. A measurement of the firmware’s own overshoot, made with a second board that does nothing but timestamp two edges. And a log format shared with the sensitometer and the densitometer, so that a printing session, an exposure series and a set of densities can sit in the same spreadsheet.
Learning objectives
Section titled “Learning objectives”By the end you should be able to:
- explain why a sleep is not a timer, and why a deadline computed once from a start tick is;
- say what the RP2 port actually offers in place of a hardware timer, and design around it;
- implement a geometric exposure series in integer milliseconds with an explicit rounding rule, and say what the rule buys;
- draw the state machine, name what the foot switch does in each state, and justify the rule that a second start aborts rather than restarts;
- schedule a metronome that cannot drift, inside a loop that must not delay the end of an exposure;
- state exactly what a watchdog protects against on this board, and what actually releases the output when the firmware fails;
- test the arithmetic before any paper is exposed, and measure the pulse rather than believing it.
Prerequisites
Section titled “Prerequisites”The timer hardware, built and passing
its input test — this page assumes the pin assignment that page fixed.
The f-stop lesson, whose
arithmetic this firmware implements and whose worked table the unit tests check against.
Part XIV’s electronics primer
for MicroPython, ticks_us, the busy-wait and the gate pull-down, none of which are re-taught here.
Safety classification
Section titled “Safety classification”Level A. No new physical hazard is introduced by writing firmware, and the rubric’s Level A criteria apply unchanged: purchased certified equipment only, no mains construction, extra-low voltage throughout.
What is not a hazard here, and why — and the one thing that is. There is nothing to spill, nothing to inhale and nothing at mains potential; the whole session is a computer, a USB lead and two Picos, and ventilation is not among its controls because nothing produces a vapour. What is real is peculiar to software: the firmware commands a light source, and its worst failure mode is an exposure that never ends. A hung loop with the output asserted will run a UV array or a high-output LED head indefinitely, which is a burn hazard and a fire risk rather than a spoiled print.
Three controls answer it and all three are in the design rather than in a warning. The output pin is
released in a finally clause on every path. The gate carries the hardware pull-down Part XIV
specified, so an undriven pin means an off lamp. And a physical master switch sits within reach,
removing power from the output channel independently of the software — which is the only control on the
list that does not depend on the thing that has just failed.
Bench-test with an indicator LED as the load, never with the UV unit. A fault found on a 20 mA LED is a fault; the same fault found on a UVA array is an eye injury and a scorched lid.
Hazards
Section titled “Hazards”- An exposure that does not end. Controls above. Do not defeat the master switch by tidying it away behind the bench.
- A bright emitter at close range if you test with the real head fitted. Part XVI’s rule stands: do not look into the emitter, and the UV array’s interlock is never bypassed to make the firmware work.
- Soldering, only if you are still changing the hardware, under Part XIV’s controls unchanged.
- The foot-switch cable on a dark floor, as on the hardware page.
Nothing on this page produces waste beyond a few centimetres of wire.
Required PPE
Section titled “Required PPE”None is required for writing and testing firmware, and saying so is a statement about this activity rather than a general one: the controls here are the master switch, the indicator-LED load and the interlock, which are engineering controls rather than personal protection. Eye protection goes on the moment a real emitter is connected for a live test, and Part XVI’s rules for the UV unit apply in full from that moment.
Ventilation is not among the controls for this session, because nothing here produces a vapour; it returns the moment an iron is picked up.
| Part | Quantity | What it does |
|---|---|---|
| The timer hardware from the previous page | 1 | The instrument under test |
| A second Raspberry Pi Pico | 1 | The probe: an independent stopwatch that does nothing else |
| USB leads | 2 | One per board; the probe needs its own serial console |
| Indicator LED and a 330 Ω resistor | 1 each | The bench load, standing in for the head |
| Jumper wires | 2 | Output to probe input, and a shared ground |
| Master switch, in line with the output channel’s supply | 1 | The control that does not depend on software |
A computer with two serial terminals open at once, a text editor, a way of copying files to a board, and a multimeter. Nothing else.
Estimated cost
Section titled “Estimated cost”Cost band £. The only new item is the second Pico, and if you built the sensitometer or the densitometer you have one already — the probe borrows a board for half an hour and gives it back.
No price is quoted, because the planner carries no electronics line at all and a figure invented here would be a guess wearing a quotation’s clothes. The specification is the durable part: any board that can timestamp an edge to the microsecond and print over a serial link will do the probe’s job, and it does not have to be the same family as the timer.
Estimated consumables cost
Section titled “Estimated consumables cost”Nothing in this session is consumed. Every item in the parts table is capital, the two boards are re-used, and the bench load’s LED outlives the instrument.
| Consumed | This session | Sourced price | Cost this session |
|---|---|---|---|
| Nothing | — | — | £0.00 |
That row is not a joke and it is not padding: it is the answer the consumables calculator needs, and a page that invented a figure to avoid an empty table would be putting noise into the one product that depends on this section. If you change the hardware while debugging, the consumables are the hardware page’s — solder, sleeving, wire — and they are priced there, or rather they are named there as unpriced.
Equipment is excluded as everywhere: the two boards, the multimeter and the master switch are not consumed by a session.
Nine stages, four hours. Stage 0 is the one that decides what the rest of the file is for.
Stage 0 — Three exposures, and only one of them is yours (20 minutes)
Section titled “Stage 0 — Three exposures, and only one of them is yours (20 minutes)”Write these on a card and keep it beside the screen.
- The commanded exposure. The number the printer set and the firmware was asked for.
- The delivered interval. How long the output was actually asserted, which differs from the first by the loop’s overhead and by whatever else the interpreter did.
- The received exposure. The light the paper actually integrated, which differs from the second by the lamp’s rise and fall and is the only one the print knows about.
This page owns the first two and can measure the gap between them. It owns none of the third: the
lamp’s behaviour is measured at the easel by the
calibration page, which produces a
number the firmware then carries as OFFSET_MS. Until that page has been run on your build, the
constant is zero, and zero is an admission rather than a value.
The glossary keeps the three apart under commanded exposure, and the reason the distinction is worth this much fuss is that every argument about a timer that “runs fast” is really an argument about which of the three somebody meant.
Where each of the three numbers is decided, and who measures it
- Commanded, in integer millisecondsthe f-stop engine computes it from the base, the divisions and the band. This file owns it and the unit tests check it.
- Delivered, measured by the firmware itselfticks_us at both ends of the exposure loop. Reported on every log line, never assumed.
- Delivered, measured independentlya second Pico timestamping both edges. The probe exists because a routine that measures itself can only find some kinds of error.
- Received by the paperthe area under the lamp's light-against-time curve, measured at the easel with the photodiode head on the calibration page. Not this page.
Stage 1 — Why a sleep is not a timer, and what this port actually gives (30 minutes)
Section titled “Stage 1 — Why a sleep is not a timer, and what this port actually gives (30 minutes)”Three facts from the documentation, and the design falls out of them.
There is no hardware timer here. MicroPython’s machine.Timer documentation says hardware timers
may be more accurate for very fine sub-millisecond timing, and that most ports support them except
Zephyr and RP2, which support only virtual timers. The RP2 quick reference agrees from the other
side: the RP2040’s system timer provides a global microsecond timebase, and what is exposed through
machine.Timer is a software timer whose callback runs as a soft interrupt unless hard=True is
passed — and a soft-interrupt callback can be delayed by garbage collection.
Ticks wrap, and only two operations are valid on them. ticks_ms and ticks_us count up from an
arbitrary origin to a value the port does not publish and then start again. The documentation is blunt
that ordinary subtraction and comparison are wrong, and that ticks_diff and ticks_add are the
operations available. Code that computes end - start works for hours and then hands you a negative
exposure once.
A sum of sleeps accumulates and a deadline does not. Sleeping 500 ms forty times does not take 20 s; it takes 20 s plus forty times whatever each sleep overshot by, and the error is one-directional. Computing every deadline from a single start tick makes each one independent of every other, so a late beat cannot push the next beat later.
Δ is the intended interval, εᵢ the overshoot of the i-th sleep, and n the count. In the first form the errors add up; in the second they cannot, because nΔ is computed from a number that was taken once. Every interval in the firmware below is the second form.
Stage 2 — The f-stop engine, and the rounding rule made concrete (40 minutes)
Section titled “Stage 2 — The f-stop engine, and the rounding rule made concrete (40 minutes)”The engine is pure arithmetic: no pins, no clock, no printing. That is deliberate, and it is what makes the unit tests runnable before any hardware exists.
Four rules govern it.
Every term is computed from the base, never from its predecessor. Iterating a multiplication by 2^(1/n) accumulates the floating-point error term by term; computing 2^(k/n) once for each k cannot. It costs one exponential per band and buys an error that does not grow with the length of the strip.
Everything is committed to integer milliseconds immediately. A float that lives on is a float that can be rounded twice.
Round the cumulative and difference the rounded values. This is the f-stop lesson’s
rule at a thousand times finer
a quantum, and strip_additions takes the cumulative list as its argument rather than computing
increments itself, so the rule is enforced by the shape of the function and cannot be forgotten by a
caller.
A tenth of a second is a different amount of exposure at every base. This is the number that decides how the encoder should behave, and it is worth having in front of you:
| Base exposure | One 0.1 s step, in stops | A twelfth-stop detent |
|---|---|---|
| 1.0 s | 0.1375 | 0.0833 |
| 1.68 s | 0.0833 | 0.0833 |
| 5.0 s | 0.0286 | 0.0833 |
| 30.0 s | 0.0048 | 0.0833 |
The two are equal at about 1.68 s. Below that a tenth of a second is a coarser adjustment than a twelfth of a stop, and above it a finer one — which is why the firmware edits the base in stops below 1.68 s and in tenths of a second above it, and why an instrument that only ever offered tenths would be clumsy at exactly the exposures a bright LED head produces.
Stage 3 — The state machine (40 minutes)
Section titled “Stage 3 — The state machine (40 minutes)”Six states, and the value of writing them down is that it makes the illegal transitions visible.
| State | Output | What START does | What the foot switch does |
|---|---|---|---|
| idle | off | Begin a timed exposure | The same |
| focus | on, continuously | Return to idle | The same |
| armed | off | Expose the next strip band | The same |
| exposing | on | Abort, and log the abort | The same |
| paused | off | Expose the next band, once SET has armed it | The same |
| burn | off | Expose the increment | The same |
The foot switch does whatever START does, in every state, always. That is the whole rule and it is what makes a pedal usable without looking: there is nothing to remember, and no state in which the pedal means something the button does not.
A second start during an exposure aborts and logs it; it does not silently restart. The reasoning is that the printer who presses START again during an exposure has almost always seen something wrong — a card in the wrong place, the wrong negative, a hand in the beam — and wants it to stop. An implementation that restarted would double the exposure of the sheet under the lens and give no indication. So the abort is a first-class outcome with its own flag in the log, and a sheet that carries an aborted exposure can be identified afterwards from the record rather than from memory.
The abort is edge detected, which matters more than it sounds. START is nearly always still held
down at the instant an exposure begins, because pressing it is what began the exposure; a level test
would abort every exposure immediately. So expose records which controls are down when it starts,
waits for each to be released, and only then treats a press as an abort.
The state machine, with the transitions that are refused
- idle — output off; the encoder edits the base
- focus — output on continuously; the metronome runs so you can count while composing
- armed — the next strip band is queued; move the card, then START
- exposing — the only state that asserts the output for a computed interval
- paused — between bands; SET arms the next one
- burn — an increment on the remembered base, in stops
- refused — a start while the output is already asserted: the interlock, logged not silent
Stage 4 — A metronome that cannot drift, inside a loop that must not be delayed (25 minutes)
Section titled “Stage 4 — A metronome that cannot drift, inside a loop that must not be delayed (25 minutes)”The beat is scheduled against the exposure’s own start tick: the k-th beat is due at k × 500 ms after the start, computed from the same tick the deadline was computed from. A beat that fires late therefore does not push the next one late, which is the whole property the design needs.
Three details make it usable rather than merely correct.
The tone is non-blocking. click() starts the PWM and records the millisecond at which it must
stop; service_tone() is called from every loop and ends it when that moment comes. A sleep_ms(12)
inside the exposure loop would be twelve milliseconds during which the deadline is not being checked,
and twelve milliseconds is more than a hundredth of a stop at a half-second exposure.
Three pitches, and the third is the one that matters. A click on the half second, a higher tone on the second, and a distinct lower tone through the final second, so that the end of an exposure is something you hear coming rather than something that happens. A printer holding a dodging card needs a second’s warning to get their hand clear.
The display goes dark for the exposure. BLANK_WHILE_EXPOSING defaults to true, and it is a fog
control rather than a nicety: the paper is out and receiving light for exactly the interval in which the
instrument has no need to show anything. This is why the metronome is not a convenience — with the
display blank, the beat is the only feedback there is.
Stage 5 — Interface behaviour in a room with no light in it (25 minutes)
Section titled “Stage 5 — Interface behaviour in a room with no light in it (25 minutes)”Encoder acceleration. Detents arriving less than 60 ms apart are multiplied by eight, so a slow turn adjusts finely and a fast turn crosses the range. The acceleration is applied in the interrupt handler, where the timestamp is, and the multiplier is a constant you can change; without it, moving from 3 s to 60 s is a wrist-aching four hundred detents.
Long press means “go somewhere”, short press means “do something”. Holding the encoder for 600 ms opens the settings walk — divisions per stop, dry-down offset, display brightness — and pressing it briefly toggles the focus lamp. One control, two meanings, distinguished by a duration your hand can produce reliably in the dark.
Every accepted action makes a sound. A short click for a detent, a longer confirmation for a mode change or a setting. In a blacked-out room with the display blanked, an action that produces no feedback is an action you cannot know was received, and the printer’s response to silence is to press again.
The dry-down offset is applied and logged separately. apply_drydown subtracts the stored
correction before the exposure, and the log carries both the base the printer set and the exposure that
was actually commanded. A firmware that silently delivered a different number from the one on the display
would be a convenience for one session and an unexplained systematic error for every session after it.
Stage 6 — Output control, the interlock, and what a watchdog actually protects (30 minutes)
Section titled “Stage 6 — Output control, the interlock, and what a watchdog actually protects (30 minutes)”One output pin, GP15, driving the gate resistor of the
switched output. It is created with
value=0 and is asserted in exactly one function.
The interlock. expose refuses to start if the output is already asserted, and prints the refusal
rather than swallowing it. That covers the case that actually happens: a focus lamp left on, then a
strip band started, which would otherwise give the band an exposure beginning at an unknown time.
A range check. Below MIN_MS and above MAX_MS the routine refuses. The upper bound is not
timidity; ten minutes is longer than any print exposure this course teaches, and a request for an hour
is a bug in the caller or a slipped encoder.
The watchdog, stated precisely. The RP2 quick reference documents machine.WDT: a countdown timer
that restarts parts of the chip if it reaches zero, with a minimum timeout of 1 s and a maximum of
8388 ms. That maximum is shorter than many print exposures, so the exposure loop must feed the
watchdog from inside itself — and that has a consequence worth stating plainly.
Stage 7 — The log (10 minutes)
Section titled “Stage 7 — The log (10 minutes)”One line per exposure, comma-separated, with # beginning every comment, so a captured terminal session
is already a data file. The columns are the same shape as the sensitometer’s and the densitometer’s — a
serial number, a kind, a millisecond tick, then the payload — so the three instruments’ logs open in the
same spreadsheet:
n,kind,ticks_ms,mode,base_ms,divisions,band,stops,commanded_ms,delivered_ms,offset_ms,flagcommanded_ms beside delivered_ms is the point of the whole file. A session’s log is then a data set
rather than a memory, and the break/fix page
is tractable precisely because the log exists: an argument about whether the prints drifted becomes a
column you can plot.
Stage 8 — The firmware in full
Section titled “Stage 8 — The firmware in full”Save the listing as timer.py on the board, then at the REPL:
import timertimer.selftest() # proves the wiring; exposes nothingtimer.test() # the arithmetic, against the printed tabletimer.session() # the instrumentEight constants near the top are meant to be changed, along with the pin numbers, and the rest is not:
OFFSET_MS, DRYDOWN_STOPS, DIVISIONS, BANDS, MIN_MS, MAX_MS, PIEZO_HZ and
COUNTS_PER_DETENT. The values in the listing are provisional starting points rather than
measurements, and the
calibration page replaces the first two
with your own.
"""timer.py - Pure Silver, Part XVII: the darkroom timer firmware.
Target Raspberry Pi Pico (RP2040), MicroPython v1.24.1 for RPI_PICO.Install copy to the board as timer.py, then at the REPL: import timer timer.selftest() # proves the wiring, exposes nothing timer.test() # the arithmetic unit tests timer.session() # the instrumentLicence CC BY-SA 4.0, as the rest of this course's content.
Everything printed is comma-separated, with '#' beginning any line that is acomment, so a captured terminal session is already the printing log. Thecolumn order is the one Parts XIV and XV already use: a serial number, a kind,a millisecond tick, then the payload.
Three rules govern this file and none of them are stylistic.
1. The output pin is switched off in a `finally` clause on every path, and its hardware pull-down holds the gate low whenever the pin is not being driven. Software is the second line of defence here, not the first. 2. Every interval is a DEADLINE computed once from a start tick, never a sum of sleeps. Deadlines do not accumulate error; sums do. 3. The routine reports the interval it ACHIEVED beside the one it was asked for. A timer that prints only its request is not an instrument."""
import gcimport machineimport mathimport micropythonimport sysimport time
micropython.alloc_emergency_exception_buf(100)
VERSION = "1.0"
# --- Wiring. The same nine numbers as the hardware page's input test. -------PIN_ENC_A = 16PIN_ENC_B = 17PIN_ENC_SW = 18PIN_START = 19PIN_MODE = 20PIN_SET = 21PIN_BUZZER = 22PIN_FOOT = 14PIN_OUT = 15PIN_SCL = 5PIN_SDA = 4
# --- Settings the calibration experiment replaces with measured ones. -------OFFSET_MS = 0 # lamp rise/fall correction; measured, not guessedDRYDOWN_STOPS = 0.0 # subtracted from every commanded exposure; yoursDIVISIONS = 3 # divisions per stop for the strip and for burnsBANDS = 5 # bands in a test stripMIN_MS = 100 # shortest exposure the interface will let you setMAX_MS = 600000 # ten minutes: the interlock's upper bound
# --- Interface constants. ---------------------------------------------------DEBOUNCE_MS = 25LONG_PRESS_MS = 600ACCEL_MS = 60 # detents closer together than this are acceleratedACCEL_FACTOR = 8COUNTS_PER_DETENT = 4PIEZO_HZ = 2400 # provisional: your transducer's resonant frequencyPIEZO_DUTY = 32768BEAT_MS = 500 # metronome half-second beatBEEP_MS = 12 # length of one metronome clickTONE_HZ = 2400 # the half-second clickSECOND_HZ = 3200 # the on-the-second tone, a distinct pitchFINAL_HZ = 1600 # the last second, distinct againWDT_MS = 5000 # 1000 is the minimum and 8388 the maximum on RP2BLANK_WHILE_EXPOSING = True # the fog rule: the display goes dark to expose
COLUMNS = ("n,kind,ticks_ms,mode,base_ms,divisions,band,stops," "commanded_ms,delivered_ms,offset_ms,flag")
IDLE, FOCUS, ARMED, EXPOSING, PAUSED, BURN = range(6)STATE_NAME = ("idle", "focus", "armed", "exposing", "paused", "burn")
# Quadrature transition table, indexed by (previous << 2) | current, where a# state is (A << 1) | B. Identical to the hardware page's input test: the# eight legal transitions give +1 or -1 and everything else gives 0._STEP = (0, 1, -1, 0, -1, 0, 0, 1, 1, 0, 0, -1, 0, -1, 1, 0)
out = machine.Pin(PIN_OUT, machine.Pin.OUT, value=0)enc_a = machine.Pin(PIN_ENC_A, machine.Pin.IN, machine.Pin.PULL_UP)enc_b = machine.Pin(PIN_ENC_B, machine.Pin.IN, machine.Pin.PULL_UP)enc_sw = machine.Pin(PIN_ENC_SW, machine.Pin.IN, machine.Pin.PULL_UP)btn_start = machine.Pin(PIN_START, machine.Pin.IN, machine.Pin.PULL_UP)btn_mode = machine.Pin(PIN_MODE, machine.Pin.IN, machine.Pin.PULL_UP)btn_set = machine.Pin(PIN_SET, machine.Pin.IN, machine.Pin.PULL_UP)foot = machine.Pin(PIN_FOOT, machine.Pin.IN, machine.Pin.PULL_UP)buzzer = machine.PWM(machine.Pin(PIN_BUZZER))buzzer.duty_u16(0)
_counts = 0_bad = 0_state = (enc_a.value() << 1) | enc_b.value()_last_edge_us = 0_accel = 1_foot_released = 1_n = 0_display = None_wdt = None
def _feed(): """Feed the watchdog from every loop that can run for longer than WDT_MS.
The RP2040's watchdog has a maximum period of 8388 ms, which is shorter than many print exposures, so a long exposure has to feed it from inside the exposure loop. That is worth being clear about: the watchdog catches a loop that has STOPPED, not a loop that is taking too long, and what actually releases the output on a reset is the gate pull-down of Part XIV rather than anything in this file. """ if _wdt is not None: _wdt.feed()
def _on_quadrature(pin): """Hard-IRQ handler: table lookup and integer arithmetic only, no alloc.""" global _state, _counts, _bad, _last_edge_us, _accel now = (enc_a.value() << 1) | enc_b.value() step = _STEP[(_state << 2) | now] if step: t = time.ticks_us() if time.ticks_diff(t, _last_edge_us) < ACCEL_MS * 1000: _accel = ACCEL_FACTOR else: _accel = 1 _last_edge_us = t _counts += step * _accel elif now != _state: _bad += 1 _state = now
enc_a.irq(trigger=machine.Pin.IRQ_RISING | machine.Pin.IRQ_FALLING, handler=_on_quadrature, hard=True)enc_b.irq(trigger=machine.Pin.IRQ_RISING | machine.Pin.IRQ_FALLING, handler=_on_quadrature, hard=True)
# --- The f-stop engine. Pure arithmetic: no pins, no clock, no printing. ----# Everything below is testable on a desktop Python as well as on the board,# which is why test() can run before any hardware exists.
def stops_to_factor(stops): """A change of `stops` stops, as a multiplier of exposure.""" return math.pow(2.0, stops)
def factor_to_stops(factor): """A multiplier of exposure, as a change in stops.""" return math.log(factor) / math.log(2.0)
def series_ms(base_ms, divisions, k): """Term k of a geometric series about base_ms, in whole milliseconds.
Every term is computed FROM THE BASE rather than from its predecessor. Iterating a multiplication would accumulate the float error term by term; computing each term once cannot. The result is committed to an integer immediately, so the rounding happens once per term and never compounds. """ return int(round(base_ms * stops_to_factor(float(k) / divisions)))
def strip_bands(base_ms, divisions=DIVISIONS, bands=BANDS, centred=True): """The cumulative exposures of a test strip, in milliseconds.
Centred on the base by default, so a five-band third-stop strip about 12.0 s runs from two thirds of a stop under to two thirds over. """ first = -(bands // 2) if centred else 0 return [series_ms(base_ms, divisions, first + i) for i in range(bands)]
def strip_additions(cumulative): """What to ADD at each step of an additive strip.
The rule the f-stop lesson derives, and the reason this function takes the cumulative list rather than computing increments itself: round the CUMULATIVE and difference the rounded values, never round the increments and add them up. Done this way each band's error is bounded by the single rounding of its own term; done the other way every band inherits every earlier rounding. """ adds = [] prev = 0 for c in cumulative: adds.append(c - prev) prev = c return adds
def burn_ms(base_ms, stops): """The EXTRA exposure for a burn of `stops` stops on a base.""" return int(round(base_ms * (stops_to_factor(stops) - 1.0)))
def dodge_ms(base_ms, stops): """The exposure WITHHELD for a dodge of `stops` stops on a base.""" return int(round(base_ms * (1.0 - stops_to_factor(-stops))))
def apply_drydown(base_ms, stops=None): """The base, less the dry-down correction, as an integer millisecond.""" s = DRYDOWN_STOPS if stops is None else stops return int(round(base_ms * stops_to_factor(-s)))
def setting_granularity_stops(base_ms, step_ms=100): """How coarse one step of the seconds-mode encoder is, in stops.
This is the number that decides which editing mode the interface should be in: a tenth of a second is 0.137 stops on a one-second base and 0.005 on a thirty-second one, while a twelfth-stop detent is 0.083 stops everywhere. """ return factor_to_stops((base_ms + step_ms) / float(base_ms))
# --- Sound. Non-blocking: the exposure loop cannot afford to sleep. ---------
_tone_off_at = None
def tone_on(hz): buzzer.freq(hz) buzzer.duty_u16(PIEZO_DUTY)
def tone_off(): global _tone_off_at buzzer.duty_u16(0) _tone_off_at = None
def click(hz=TONE_HZ, ms=BEEP_MS): """Start a click and record when it must stop. Never blocks.""" global _tone_off_at tone_on(hz) _tone_off_at = time.ticks_add(time.ticks_ms(), ms)
def service_tone(): """Called from every loop: ends a click when its moment arrives.""" if _tone_off_at is not None and time.ticks_diff(_tone_off_at, time.ticks_ms()) <= 0: tone_off()
def confirm(ok=True): """A blocking confirmation, used only where blocking is harmless.""" tone_on(SECOND_HZ if ok else FINAL_HZ) time.sleep_ms(40 if ok else 160) tone_off()
# --- Display. Absent by default; the instrument runs headless. -------------
def attach_display(): """Import a driver if one is present. The course publishes no driver.""" global _display try: import ssd1306 i2c = machine.I2C(0, scl=machine.Pin(PIN_SCL), sda=machine.Pin(PIN_SDA)) _display = ssd1306.SSD1306_I2C(128, 64, i2c) return True except Exception as exc: print("# no display ({}); running headless".format(exc)) _display = None return False
def show(line1, line2="", bright=None): if _display is None: return try: if bright is not None: _display.contrast(bright) _display.fill(0) _display.text(line1, 0, 16) _display.text(line2, 0, 40) _display.show() except Exception: pass
def blank(): """Black the panel. Called before every exposure when the fog rule is on.""" if _display is None: return try: _display.fill(0) _display.show() except Exception: pass
# --- Inputs. ---------------------------------------------------------------
def learn_foot_polarity(): """Take whatever the pedal reads at boot as its released state.""" global _foot_released n = 0 total = 0 end = time.ticks_add(time.ticks_ms(), 200) while time.ticks_diff(end, time.ticks_ms()) > 0: total += foot.value() n += 1 time.sleep_ms(5) _foot_released = 1 if total * 2 > n else 0 return _foot_released
def foot_pressed(): return foot.value() != _foot_released
def detents(): return _counts // COUNTS_PER_DETENT
def take_detents(): """Detents turned since the last call, and reset.
Interrupts are masked across the read-modify-write, because the encoder's handler writes the same variable and a detent lost between the read and the subtraction is a number the printer set and the instrument did not. """ global _counts mask = machine.disable_irq() try: d = _counts // COUNTS_PER_DETENT _counts -= d * COUNTS_PER_DETENT finally: machine.enable_irq(mask) return d
def pressed(pin, since): """True once per debounced press. `since` holds each pin's last edge.
`ticks_diff` is the only valid arithmetic on a tick value, and it may only be given values that came from `ticks_ms` - which is why a pin that has never been pressed is stored as None rather than as a sentinel number. """ key = id(pin) down = pin.value() == 0 last = since.get(key) if not down: since[key] = None return False if last is None: since[key] = time.ticks_ms() return True return False
def held_for(pin, ms): """Block until the pin is released, and report whether it was held `ms`.""" start = time.ticks_ms() while pin.value() == 0: _feed() if time.ticks_diff(time.ticks_ms(), start) > ms: return True time.sleep_ms(5) return False
# --- Logging. --------------------------------------------------------------
def _log(kind, mode, base_ms, band, stops, commanded, delivered, offset, flag): global _n _n += 1 print("{},{},{},{},{},{},{},{:.4f},{},{},{},{}".format( _n, kind, time.ticks_ms(), mode, base_ms, DIVISIONS, band, stops, commanded, delivered, offset, flag))
# --- The exposure. This is the only place the output is ever asserted. ------
def expose(commanded_ms, mode="timed", base_ms=0, band=0, stops=0.0, metronome=True): """Assert the output for commanded_ms and report what actually happened.
Returns (delivered_ms, flag). The flag is 'ok', or 'abort' if a second START or a foot press ended it early.
Three properties are deliberate.
The interval is a DEADLINE measured from one start tick, so nothing that happens inside the loop can move the end. The deadline is tested FIRST in every pass, before the metronome and the buttons, so housekeeping can delay the end by at most one pass of the loop and never by a whole beat.
The loop is not a pure busy-wait, unlike the sensitometer's, because this instrument has to beat time and to watch for an abort while it exposes. That costs some microseconds of overshoot per pass, which is why the routine measures the interval it achieved instead of asserting the one it was asked for, and why loopback() exists.
The output is released in a `finally` clause on every path, including a keyboard interrupt at the REPL.
The abort is EDGE detected. START is almost always still down at the moment the exposure begins, because pressing it is what began the exposure; a level test would abort every exposure instantly. So the routine records which controls are down when it starts, waits for each to go up, and only then treats a press as an abort. """ if out.value(): print("# REFUSED: the output is already asserted. Interlock.") return 0, "interlock" if commanded_ms < MIN_MS or commanded_ms > MAX_MS: print("# REFUSED: {} ms is outside {} to {} ms.".format( commanded_ms, MIN_MS, MAX_MS)) return 0, "range" gc.collect() # so a collection is unlikely mid-exposure if BLANK_WHILE_EXPOSING: blank() target_us = commanded_ms * 1000 beat = 1 flag = "ok" start_armed = btn_start.value() == 0 # down already: not an abort yet foot_armed = foot_pressed() start = time.ticks_us() out.value(1) try: while True: now = time.ticks_us() elapsed = time.ticks_diff(now, start) if elapsed >= target_us: break _feed() if metronome: due = beat * BEAT_MS * 1000 if elapsed >= due: remaining = target_us - elapsed if remaining <= 1000000: click(FINAL_HZ) elif beat % 2 == 0: click(SECOND_HZ) else: click(TONE_HZ) beat += 1 service_tone() start_down = btn_start.value() == 0 foot_down = foot_pressed() if not start_down: start_armed = False if not foot_down: foot_armed = False if (start_down and not start_armed) or (foot_down and not foot_armed): flag = "abort" break finally: out.value(0) delivered = time.ticks_diff(time.ticks_us(), start) tone_off() delivered_ms = delivered // 1000 _log("exposure", mode, base_ms, band, stops, commanded_ms, delivered_ms, OFFSET_MS, flag) return delivered_ms, flag
# --- The state machine. ----------------------------------------------------
def session(base_ms=12000, wdt_enabled=True): """Warm the instrument up, then run until interrupted.
States: idle, focus, armed, exposing, paused, burn.
START idle -> exposing, armed -> exposing, burn -> exposing; during an exposure it ABORTS and the abort is logged. MODE cycles timed / strip / burn. SET long press enters the settings walk; short press arms the next band of a strip. ENC_PUSH idle -> focus and back; the lamp is on continuously in focus and the metronome runs so you can count while you compose. FOOT whatever START does in the current state. That is the whole rule, and it is what makes the pedal usable without looking. """ global _wdt _wdt = machine.WDT(timeout=WDT_MS) if wdt_enabled else None header() learn_foot_polarity() attach_display() print(COLUMNS) state = IDLE mode = "timed" strip = [] band = 0 since = {} foot_was = foot_pressed() show("READY", "{:.1f}s".format(base_ms / 1000.0)) try: while True: _feed() service_tone() d = take_detents() if d and state in (IDLE, PAUSED): if base_ms >= 1682: base_ms = max(MIN_MS, min(MAX_MS, base_ms + d * 100)) else: base_ms = max(MIN_MS, min(MAX_MS, series_ms(base_ms, 12, d))) show("{:.1f}s".format(base_ms / 1000.0), mode) click(TONE_HZ, 6) if pressed(btn_mode, since): mode = {"timed": "strip", "strip": "burn", "burn": "timed"}[mode] strip = [] band = 0 state = IDLE confirm() show(mode, "{:.1f}s".format(base_ms / 1000.0)) if pressed(enc_sw, since): if held_for(enc_sw, LONG_PRESS_MS): settings() show("READY", "{:.1f}s".format(base_ms / 1000.0)) elif state == FOCUS: out.value(0) state = IDLE show("READY", "{:.1f}s".format(base_ms / 1000.0)) confirm() elif state == IDLE: state = FOCUS out.value(1) show("FOCUS", "encoder to end") confirm() if pressed(btn_set, since) and mode == "strip": if not strip: strip = strip_additions(strip_bands(apply_drydown(base_ms))) band = 0 state = ARMED show("BAND {}".format(band + 1), "+{:.1f}s".format(strip[band] / 1000.0)) confirm() foot_now = foot_pressed() foot_edge = foot_now and not foot_was foot_was = foot_now if pressed(btn_start, since) or foot_edge: if state == FOCUS: out.value(0) state = IDLE elif mode == "strip" and state == ARMED: state = EXPOSING expose(strip[band], "strip", base_ms, band + 1, float(band - BANDS // 2) / DIVISIONS) band += 1 if band >= len(strip): strip = [] band = 0 state = IDLE show("STRIP DONE", "{:.1f}s".format(base_ms / 1000.0)) else: state = PAUSED show("MOVE CARD", "then SET") elif mode == "burn": stops = 0.5 state = EXPOSING expose(burn_ms(apply_drydown(base_ms), stops), "burn", base_ms, 0, stops) state = IDLE show("BURN DONE", "+{:.2f} stop".format(stops)) else: state = EXPOSING expose(apply_drydown(base_ms), "timed", base_ms, 0, -DRYDOWN_STOPS) state = IDLE show("DONE", "{:.1f}s".format(base_ms / 1000.0)) while foot_pressed(): _feed() time.sleep_ms(10) foot_was = False time.sleep_ms(2) finally: out.value(0) tone_off() print("# session ended with the output off")
def settings(): """A three-item walk: divisions per stop, dry-down, display brightness.""" global DIVISIONS, DRYDOWN_STOPS items = ("divisions", "drydown", "brightness") i = 0 bright = 128 since = {} show("SETTINGS", items[i]) confirm() while True: _feed() service_tone() d = take_detents() if d: if items[i] == "divisions": DIVISIONS = max(1, min(12, DIVISIONS + d)) show("divisions", str(DIVISIONS)) elif items[i] == "drydown": DRYDOWN_STOPS = max(0.0, min(1.0, DRYDOWN_STOPS + d * 0.02)) show("drydown", "{:.2f} stop".format(DRYDOWN_STOPS)) else: bright = max(1, min(255, bright + d * 8)) show("brightness", str(bright), bright) click(TONE_HZ, 6) if pressed(btn_mode, since): i = (i + 1) % len(items) show("SETTINGS", items[i]) confirm() if pressed(enc_sw, since): print("# settings: divisions {}, drydown {:.2f} stop, bright {}" .format(DIVISIONS, DRYDOWN_STOPS, bright)) confirm() return time.sleep_ms(5)
def header(): print("# Pure Silver timer firmware v{}".format(VERSION)) print("# {}".format(sys.implementation)) print("# divisions {}, bands {}, offset {} ms, drydown {:.2f} stop" .format(DIVISIONS, BANDS, OFFSET_MS, DRYDOWN_STOPS)) print("# blank display while exposing: {}".format(BLANK_WHILE_EXPOSING))
# --- Proving it, before it is trusted with paper. --------------------------
def selftest(): """Every input at rest, the output low, one audible confirmation.""" header() out.value(0) learn_foot_polarity() print("# foot released state {}".format(_foot_released)) print("# resting: enc_sw {} start {} mode {} set {}".format( enc_sw.value(), btn_start.value(), btn_mode.value(), btn_set.value())) if 0 in (btn_start.value(), btn_mode.value(), btn_set.value()): print("# FAIL: a button reads pressed with nothing touching it.") return False if out.value(): print("# FAIL: the output is asserted at rest.") return False confirm() print("# PASS: inputs at rest, output low.") return True
def test(): """Unit tests for the arithmetic, against the table on the f-stop page.
Runs on the board and on a desktop Python alike, because nothing it calls touches a pin or a clock. Run it after any change to the engine, and run it before you believe a single exposure. """ fails = 0
def check(name, got, want): if got != want: print("# FAIL {}: got {} want {}".format(name, got, want)) return 1 return 0
cum = strip_bands(12000, 3, 5) fails += check("strip cumulative", cum, [7560, 9524, 12000, 15119, 19049]) fails += check("strip additions", strip_additions(cum), [7560, 1964, 2476, 3119, 3930]) fails += check("additions sum to the last band", sum(strip_additions(cum)), cum[-1]) tenths = [int(round(c / 100.0)) for c in cum] fails += check("as displayed, tenths", tenths, [76, 95, 120, 151, 190]) fails += check("one whole stop up", series_ms(12000, 1, 1), 24000) fails += check("one whole stop down", series_ms(12000, 1, -1), 6000) fails += check("half stop", series_ms(12000, 2, 1), 16971) fails += check("burn half a stop", burn_ms(12000, 0.5), 4971) fails += check("burn one stop is the base again", burn_ms(12000, 1.0), 12000) fails += check("dodge one stop is half the base", dodge_ms(12000, 1.0), 6000) fails += check("dodge half a stop", dodge_ms(12000, 0.5), 3515) fails += check("drydown of zero changes nothing", apply_drydown(12000, 0.0), 12000) fails += check("drydown of a third of a stop", apply_drydown(12000, 1.0 / 3.0), 9524) g1 = setting_granularity_stops(1000) g30 = setting_granularity_stops(30000) if not (0.137 < g1 < 0.138 and 0.0047 < g30 < 0.0049): print("# FAIL granularity: {:.4f} at 1 s, {:.4f} at 30 s".format(g1, g30)) fails += 1 print("# granularity of a 0.1 s step: {:.4f} stops at 1 s, " "{:.4f} stops at 30 s".format(g1, g30)) print("# a twelfth-stop detent is 0.0833 stops at every base;") print("# the two are equal at about 1.68 s, which is why the encoder") print("# edits in stops below that and in tenths above it.") if fails: print("# {} TEST(S) FAILED".format(fails)) else: print("# all arithmetic tests passed") return fails
def loopback(n=20, ms=1000): """Measure the pulse this firmware actually produces, without a lamp.
Run timer_probe.py on a second board with its input on the output pin and a shared ground. This routine makes n exposures at a fixed cadence and prints its own view; the probe prints the independent one. The difference between the two columns is what this instrument may claim. """ print("# loopback: {} exposures of {} ms".format(n, ms)) print(COLUMNS) over = [] for _ in range(n): delivered, flag = expose(ms, "loopback", ms, 0, 0.0, metronome=False) if flag == "ok": over.append(delivered - ms) time.sleep_ms(500) if over: worst = max(over) mean = sum(over) / len(over) print("# overshoot: mean {:.1f} ms, worst {} ms over {} runs".format( mean, worst, len(over))) print("# as a fraction of a stop at this exposure: {:.5f}".format( factor_to_stops((ms + worst) / float(ms)))) return over
def soak(n=100, ms=2000, cadence_ms=3000): """A hundred exposures, logged, so the outliers can be looked for.
The point is not the mean. It is whether any single exposure is far from it, because one bad exposure in a hundred is one ruined print in a hundred, and a mean will hide it completely. """ print("# soak: {} exposures of {} ms at {} ms cadence".format( n, ms, cadence_ms)) print(COLUMNS) delivered = [] for _ in range(n): d, flag = expose(ms, "soak", ms, 0, 0.0, metronome=False) if flag == "ok": delivered.append(d) time.sleep_ms(cadence_ms) if len(delivered) > 1: mean = sum(delivered) / len(delivered) var = sum((x - mean) ** 2 for x in delivered) / (len(delivered) - 1) sd = math.sqrt(var) print("# delivered mean {:.1f} ms, sd {:.2f} ms, min {}, max {}".format( mean, sd, min(delivered), max(delivered))) print("# spread as stops: {:.5f}".format( factor_to_stops(max(delivered) / float(min(delivered))))) return deliveredStage 9 — The probe
Section titled “Stage 9 — The probe”The second board. It does nothing but timestamp two edges, which is exactly why it can be believed about a quantity the first board cannot measure about itself.
"""timer_probe.py - Pure Silver, Part XVII: an independent stopwatch.
Target a SECOND Raspberry Pi Pico, MicroPython v1.24.1 for RPI_PICO.Wiring probe GP16 <- timer GP15 (the output pin, before the gate resistor) probe GND <- timer GND (one wire, one connection, essential)Install copy to the second board as timer_probe.py, then at its REPL: import timer_probe as p p.watch(20)Licence CC BY-SA 4.0, as the rest of this course's content.
Why a second board rather than the datasheet: the quantity in question is notthe crystal's accuracy, which Part XIV showed is 150 times better thananything here needs. It is the interpreter's loop overhead, the interruptlatency and whatever the garbage collector does, and none of those is in anydatasheet. A board that does nothing but timestamp two edges measures allthree at once.
The probe's own overhead is its interrupt latency, which is small, constantand in the same direction on both edges - so it very nearly cancels out of apulse width. It does not cancel out of a delay between boards, which is whythis file measures widths and not offsets."""
import machineimport micropythonimport sysimport time
micropython.alloc_emergency_exception_buf(100)
PIN_WATCH = 16ACTIVE_HIGH = True # the timer asserts its output high to switch the lamp
watch_pin = machine.Pin(PIN_WATCH, machine.Pin.IN, machine.Pin.PULL_DOWN)
_rise = None_widths = []
def _edge(pin): """Hard-IRQ handler: one tick read, one comparison, no allocation.""" global _rise t = time.ticks_us() if pin.value() == (1 if ACTIVE_HIGH else 0): _rise = t elif _rise is not None: _widths.append(time.ticks_diff(t, _rise)) _rise = None
watch_pin.irq(trigger=machine.Pin.IRQ_RISING | machine.Pin.IRQ_FALLING, handler=_edge, hard=True)
def watch(n=20, timeout_s=600): """Report the width of the next n pulses, in microseconds.
Start this first, then run loopback() or soak() on the timer. Every line is comma-separated so a captured session goes straight into a spreadsheet beside the timer's own log, and the two columns can be subtracted. """ print("# Pure Silver timer probe, watching GP{}".format(PIN_WATCH)) print("# {}".format(sys.implementation)) print("pulse,width_us,width_ms") _widths.clear() seen = 0 end = time.ticks_add(time.ticks_ms(), timeout_s * 1000) while seen < n and time.ticks_diff(end, time.ticks_ms()) > 0: while seen < len(_widths): w = _widths[seen] seen += 1 print("{},{},{:.3f}".format(seen, w, w / 1000.0)) time.sleep_ms(20) if seen < 2: print("# FAIL: {} pulse(s) seen. Check the signal wire and the".format(seen)) print("# shared ground, and that ACTIVE_HIGH matches the output.") return [] got = _widths[:seen] mean = sum(got) / len(got) spread = max(got) - min(got) print("# {} pulses: mean {:.1f} us, min {}, max {}, spread {} us".format( len(got), mean, min(got), max(got), spread)) print("# spread as a fraction of the mean: {:.5f}".format(spread / mean)) return gotTesting and calibration
Section titled “Testing and calibration”Five tests, in order, and the fifth is the one that decides what the instrument may claim.
T1 — The arithmetic, before any hardware
Section titled “T1 — The arithmetic, before any hardware”timer.test(). It touches no pin and reads no clock, so it runs on the board and on a desktop Python
alike. Every value it checks comes from the worked table on the
f-stop lesson, which is the
point: the firmware is checked against the arithmetic a reader can do by hand, not against itself.
Run it after every change to the engine, and run it before you believe a single exposure.
T2 — Every input at rest, and the output low
Section titled “T2 — Every input at rest, and the output low”timer.selftest(). It prints the resting state of every control, learns the foot switch’s polarity, and
fails if any button reads pressed with nothing touching it or if the output is asserted at rest.
T3 — The pulse, measured by something else
Section titled “T3 — The pulse, measured by something else”Wire the probe: its GP16 to the timer’s GP15, and one ground wire between the two boards. Start
p.watch(20) on the probe, then timer.loopback(20, 1000) on the timer, and put the two logs side by
side.
What you are looking for is not agreement to the microsecond. It is three numbers:
- the mean overshoot, which is the loop’s own cost. Part XIV measured a pure busy-wait’s overhead at a few tens of microseconds, and this loop does three things per pass rather than one, so expect more than that — but the number you write down is the one you measured, not the one you expected;
- the worst overshoot over twenty runs, which is the number that goes on the certificate;
- the spread, which is what turns into stops and therefore into visible density.
Repeat at 200 ms and at 10 s. The overshoot should be roughly constant in absolute terms, which means it is a much larger fraction of a short exposure — and that, not the crystal, is what sets the shortest exposure this firmware can honestly command.
T4 — A hundred exposures, looking for the one that is wrong
Section titled “T4 — A hundred exposures, looking for the one that is wrong”timer.soak(100, 2000). The mean is not the point and neither is the standard deviation. Sort the
delivered column and look at the extremes, because one bad exposure in a hundred is one ruined print in
a hundred, and a mean will hide it completely. A single outlier of tens of milliseconds is worth
chasing: the likeliest cause is a garbage collection that fell inside the exposure.
T5 — What has been verified, and what has not
Section titled “T5 — What has been verified, and what has not”This is a test of the page rather than of the firmware, and it belongs in the record.
Verified. The listing imports cleanly and its arithmetic unit tests pass, run for this page with the
hardware layer stubbed out: the five-band third-stop strip, the additive increments, the identity that
the increments sum to the last band, the display rounding to tenths, whole and half stops, burn and dodge
in both directions, the dry-down application, and the granularity figures. selftest passes against
stubbed pins, expose honours its deadline, the interlock and the range check refuse as designed, and a
START held down at the start of an exposure does not abort it.
Not verified. No part of this file has been run on a Raspberry Pi Pico by this course, because no
Pure Silver timer has been built yet. Every timing figure in it is therefore a design intention rather
than a measurement, OFFSET_MS is zero because nothing has measured it, and the interrupt handler’s
behaviour under a real encoder’s bounce is the hardware page’s open question rather than a settled one.
The version stated in the header is the interpreter the course standardises on across Parts XIV, XV and
XVII, not a version this listing has been observed to run under.
That paragraph is the honest form of what other projects write as “tested and working”. When somebody builds the instrument and runs T3 and T4, this section gets shorter.
Troubleshooting
Section titled “Troubleshooting”| Symptom | Likely cause | Test that separates it |
|---|---|---|
| Every exposure aborts the instant it starts | An abort detected on a level rather than an edge, or a foot switch whose polarity was learned with your foot on it | Reboot with your foot off the pedal; selftest() prints the polarity it learned |
# REFUSED: the output is already asserted |
The focus lamp was left on | Press the encoder to leave focus; the interlock is working as designed |
| The delivered interval exceeds the commanded one by milliseconds rather than microseconds | Something inside the loop is blocking — a sleep_ms, a display write, a print |
Comment out the metronome and re-run loopback(); the difference is the cost of the thing you removed |
| One exposure in a hundred is tens of milliseconds long | A garbage collection inside the exposure | Re-run soak() immediately after a reset; if the outliers move to later in the run, the heap is the cause |
| The board resets a few seconds after Ctrl-C | The watchdog is doing its job | session(wdt_enabled=False) for bench work |
| The encoder jumps eight at a time | Acceleration, working | Turn it slowly; if it still jumps, lower ACCEL_FACTOR or raise ACCEL_MS |
| The strip’s bands are not evenly spaced on the print | Could be the firmware, the lamp or the paper | test() proves the arithmetic; loopback() proves the pulse; anything left belongs to the calibration page |
| A negative exposure or an interval of about 71 minutes appears in the log | Tick arithmetic done with - instead of ticks_diff somewhere |
Search the file for a subtraction of two tick values; there should be none |
| The metronome drifts behind over a long exposure | Beats counted with sleeps rather than scheduled against the start tick | The published loop schedules; a modified one may not |
Questions
Section titled “Questions”- Explain, in terms of the two forms in stage 1’s equation, why a metronome built on
sleep_ms(500)drifts and one built on deadlines does not — and say what the drift would be over a two-minute exposure if each sleep overshot by 3 ms. strip_additionstakes the cumulative list as its argument instead of computing increments from the base itself. Say what error that shape makes impossible, and why a comment saying “round the cumulative” would have been a weaker guarantee.- The exposure loop tests the deadline before it services the metronome and the buttons. State what would change if the order were reversed, and estimate the size of the effect.
- Your
loopback()reports a worst overshoot of 380 µs. Express that as a fraction of a stop at a commanded 0.2 s and at a commanded 20 s, and say which of those two exposures the figure constrains. - A watchdog is described in a forum post as “making sure the lamp cannot stay on”. Correct the statement precisely, naming what actually releases the output and what the watchdog does contribute.
- The firmware applies a dry-down offset and logs the base and the commanded exposure separately. Construct the failure that would follow from applying it silently, and say why it would take months to notice.
- You change
DIVISIONSfrom 3 to 6 and the strip now has bands that differ by less than your instrument’s measured spread. Say how you would know, from the numbers you already have, and what you would do about it.
Further experiments
Section titled “Further experiments”Measure the loop’s cost directly. Add a counter to the exposure loop that increments on every pass, and print passes-per-second beside the delivered interval. The reciprocal is the granularity of the deadline test, and it is the tightest bound on the overshoot you can state without the probe.
Test the drift hypothesis on the metronome. Rewrite expose to count beats with sleep_ms(500)
instead of scheduling them, run a two-minute exposure with the probe watching, and measure how far behind
the last beat lands. That number is what the deadline design is worth, and it is more convincing measured
than argued.
Find your own crossover. The 1.68 s at which a tenth of a second and a twelfth of a stop are equal
falls out of the arithmetic, but the useful crossover depends on your instrument’s measured spread. Run
loopback() at eight exposures from 0.2 s to 30 s, convert each spread to stops, and plot it: where the
curve crosses your smallest intended step is the shortest exposure at which your steps mean anything.
Log a whole printing session and read it as data. Make a real print with the log captured, then plot
delivered_ms against n. Anything that is not a flat line with the strip’s steps in it is a question,
and the break/fix page is where those
questions get answered.
Check your understanding
Sources for this page
6 cited · checked 2026-09-05
- 01Quick reference for the RP2, MicroPython documentationDamien P. George, Paul Sokolovsky and contributors§ Timers - the RP2040's hardware timers are not exposed and machine.Timer provides virtual timers whose callbacks are prone to garbage-collection jitter and delays unless hard=True is passed; Delay and timing, with time.ticks_ms and time.ticks_diff; WDT, the watchdog timer, enabled with a timeout of which 1 s is the minimum and 8388 ms the maximum; Pins and GPIO, the internal pull-up and Pin.irq; hardware I2C, where machine.I2C(0) takes the port default of scl on Pin 5 and sda on Pin 4docs.micropython.org/en/latest/rp2/quickref.htmltier 1, primary2026-09-05
- 02class Timer, control hardware timers, MicroPython library documentationDamien P. George, Paul Sokolovsky and contributors§ class Timer - the distinction between virtual and hardware timers, the note that most ports support hardware timers except Zephyr and RP2 which support only virtual timers, and the hard keyword, where False schedules the callback as a soft interrupt allowing allocation but possibly introducing garbage-collection delays and jitterdocs.micropython.org/en/latest/library/machine.Timer.htmltier 1, primary2026-09-05
- 03time, time related functions, MicroPython library documentationDamien P. George, Paul Sokolovsky and contributors§ ticks_ms, ticks_us and ticks_cpu - the values wrap around and only ticks_diff and ticks_add are valid on them, with the documented patterns for polling with a timeout and for scheduling events against a deadlinedocs.micropython.org/en/latest/library/time.htmltier 1, primary2026-09-05
- 04Raspberry Pi Pico Datasheet: An RP2040-based microcontroller boardRaspberry Pi Ltd§ Section 1, key features - a dual-core Cortex-M0+ at up to 133 MHz, 26 multi-function 3.3 V GPIO and one timer with four alarms; section 4.2, general purpose IOdatasheets.raspberrypi.com/pico/pico-datasheet.pdftier 1, primary2026-09-05
- 05RP2040 Datasheet: A microcontroller by Raspberry PiRaspberry Pi Ltd§ Section 2.19, GPIO - output drive strength settable to 2 mA, 4 mA, 8 mA or 12 mA; and the pad's behaviour as an input when the pin is not drivendatasheets.raspberrypi.com/rp2040/rp2040-datasheet.pdftier 1, primary2026-09-05
- 06MULTIGRADE RC Papers, technical informationHARMAN technology Limited (ILFORD Photo), 2020§ ISO Range (R) - the table of range figures to ISO 6846:1992, used here only to convert a rounding error into the fraction of a paper's scale it representsilfordphoto.com/wp/wp-content/uploads/2021/01/MULTIGRADE-RC-Papers-J20.pdftier 1, primary2026-09-05
Formulas, hazard statements, historical dates and process descriptions on this page were checked against the sources above on the date shown. Safety data changes: obtain the current safety data sheet for the product you actually buy before you open it.