Skip to content

Contact force checks - #9

Merged
Boernsman merged 34 commits into
mainfrom
contact-force-checks
Oct 14, 2026
Merged

Boernsman merged 34 commits into
mainfrom
contact-force-checks

Conversation

@Boernsman

@Boernsman Boernsman commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

What this changes

Checks

  • scripts/lint.sh && scripts/test.sh pass.
  • New or changed behaviour has a test.
  • CHANGELOG.md has an entry under [Unreleased], if this is user-visible.

CONTRIBUTING.md asks two questions that CI cannot answer for you. Answer the
ones that apply, and delete the rest.

  • Touched anything under src/pinside/kicad/? The KiCad tests run in
    CI in a container, but a real KiCad install is the only place project
    output gets opened. Say which KiCad version you ran against.
  • Touched anything under src/pinside/firmware/templates/? CI compiles
    the host tests with the mock HAL and cross-compiles against the Pico SDK.
    Say whether you flashed it and what the fixture did.
  • Added or changed a target in targets.py? Name the datasheet and the
    table you checked the function map against. A wrong map produces a config
    that validates and does not work.
  • Changed a probe in pogo.py? Cite the supplier drawing. These numbers
    end up as drill sizes on a board somebody orders.

@Boernsman
Boernsman force-pushed the contact-force-checks branch from d42e97a to b63e7f3 Compare October 14, 2026 19:17
Everything the fixture does today is observational or digital: adc reads a
rail, gpio reads a flag or pulls a line, the buses talk a protocol. In every
case the DUT owns the signal and the fixture stays high-Z until told
otherwise, which is why open_drain means released rather than driven high.

That covers the half of a board that talks and none of the half that senses.
A product with a PT100 input has an excitation current, an amplifier, a
conversion curve, a range check and an open-sensor alarm, and no part of that
chain can be tested without either a calibration bath or a fixture that
pretends to be the sensor. No code here; this is the argument, written down
before it is built, because injection is the first feature that breaks the
existing invariants rather than extending them.

docs/signal-injection.md makes that case. A probe stops being
one-directional, so "the real sensor must not be fitted" is a precondition
guard cannot express. A channel stops being one net, so an injector is
structurally a bus rather than a GPIO. Accuracy becomes a claim no test in CI
can check. And a second voltage domain appears, which is the reason to keep
the first release inside the logic rail -- at 1 mA excitation a 100 ohm
element develops 100 mV.

It also picks the topology and says why the better-looking ones lose. A
digital potentiometer's 50-100 ohm wiper is the whole element at 100 ohm. An
active synthetic resistor is the best end state and the worst first move:
nothing about it can be checked without a bench session, and a wrong one lies
rather than failing. A reed-relay ladder wins on a ground that outranks the
analogue arguments -- it needs no new HAL functions, being fx_hal_gpio_set
eight times, and CONTRIBUTING.md prices a second vendor at 17 of them. The
error budget is in the note; the honest claim is +/-1.5 C uncalibrated and
+/-0.7 C with contact resistance measured once, which proves a front-end
reads, scales and alarms, and is not a calibrator.

ARCHITECTURE.md is the map the repository did not have: what each layer may
assume, the invariants that make the output trustworthy, and where a new
piece of hardware support goes. Its centrepiece is the design-module system,
one directory per quantity holding a descriptor, a sample schematic, a
firmware driver, a configure() script and a version. Two decisions in there
are load-bearing.

Modules do not add RPC methods. The core owns a fixed surface -- sim.set,
sim.get, sim.fault, sim.verify, sim.channels -- and dispatches by channel to
an fx_inject_driver vtable. A protocol that grows with the module set means
every new module can break the contract test_contract.py enforces against
fixture_core.c's dispatch table, and openrpc.json is never stable.

And configure() must be pure. It turns a wanted range into component values,
firmware constants and findings, and one run feeds all three so they cannot
disagree; the worked example attenuates a 4.096 V reference to 1.000 V full
scale, lands on 6.19k over 2.00k, and reports the 1.51 kohm output impedance
a hand-built divider would have given silently. The reproducibility
guarantee -- an unchanged config regenerates byte-identically, config_hash is
stable -- dies the moment a module computes a different answer on Tuesday, so
the module test calls it twice and compares. Module semver is defined by what
the owner of a built fixture must do: MAJOR means re-fabricate.

The other half is the input side, and it comes first. Every statement pinside
makes about a probe's purpose is inferred from a string somebody chose for
another reason: classify() is a regex on the net name, PS033 reads a leading
plus, scaffold.py groups by a shared token, and PS041 exists to report that
the inference has a floor. A Pinside field on a test point in the DUT project
replaces guessing with declaration, and it has to land before injection,
because an injector names a probe pair and a reference node and writing the
injection config format first means guessing at it twice.

The grammar is a role plus named keys, and roles fall into three groups, only
one of which is a test: pins the fixture measures, pins where it presents the
thing under test, and service pins that exist so the fixture can work at all
-- a power feed, its return, a Kelvin sense tap, a reference in either
direction. Leaving that third group out is how a coverage number fills up
with pins that prove nothing, because a power feed reaching a channel is
meaningless. Every role is named from the DUT's side, matching the existing
uart:tx convention; naming them fixture-relative would have made the grammar
self-contradictory.

Service pins carry the arithmetic nobody does: contact resistance is tens of
milliohms, so one amp through one 50 mohm contact is 50 mV, and a 3.3 V rail
held to +/-1% has 33 mV of budget in total. The probe busts the tolerance
before the regulator has done anything wrong. That is why current= and a
sense:hi pad are on the label -- the drop sits outside the regulation loop,
so sensing past the probes lets the regulator correct for them -- and why gnd
and power:return are separate roles.

Writing that exposed a gap worth recording rather than papering over. pogo.py
knows a probe's hole, pad, body, pitch, travel, force and tip, and nothing
about what it carries, so PS036 and PS037 have nothing to check against until
Probe grows current_a and contact_mohm with their own provenance. The guide
says so where somebody about to trust a check will read it, and the tasklist
puts those fields in the same bench session as verifying the rest of the
catalogue. The dependency is the right way round: the declaration is worth
having before the check, because the check is useless without it and trivial
with it.

TASKLIST.md is reorganised by release rather than by subject, 0.3.0 through
0.7.0 plus what is gated on something outside the repository and what is
simply ongoing. Every open item from the previous structure kept, rehomed,
and given the release whose theme it belongs to. Planning by subject made it
impossible to see what any one release was for.

Verified against KiCad 10.0.3 rather than assumed, because the label
mechanism is the one part of this with a factual answer. A custom footprint
property survives KiCad's own parser and writer: injecting (property "Pinside"
"switch:vbus") and running kicad-cli pcb upgrade --force returns it intact,
promoted to a full field with a uuid, a layer and text effects. board.py's
existing _property() already reads it back unchanged, so the reader costs one
call. At the other end, a custom field on a symbol reaches the netlist pcbnew
consumes, as (field (name "Pinside") "switch:vbus") in the component's fields
block. The remaining hop -- Update PCB from Schematic copying it onto the
footprint -- is a checkbox in a dialog kicad-cli cannot drive, so it is the
first item in 0.3.0 rather than a claim in the guide. KiCad writes the field
visible on F.Fab, which the guide says to untick.

No behaviour changed, so there is no CHANGELOG entry: the suite is 229 tests
and scripts/lint.sh is clean, both unchanged from before. The documented
finding codes are deliberately absent from the README's tables, since
test_docs.py treats any code named there as one the source must emit.
Every statement pinside made about a probe's purpose was inferred from a
string somebody chose for another reason. classify() is a regex over English
mnemonics, PS033 reads a leading plus, scaffold.py groups by a shared token,
and PS041 exists to report that the inference has a floor. A `Pinside` field
on a test point in the DUT's own board replaces the guess with a declaration
the board carries:

    (property "Pinside" "switch:vbus")

The grammar is a role plus named keys, parsed in labels.py, and roles fall
into three groups: pins the fixture measures, pins where it presents the thing
under test, and service pins that exist so the fixture can work at all. Every
role is named from the DUT's side, matching the existing uart:tx convention --
labelling by what the fixture does produces a fixture wired backwards with no
error to explain why, which is the failure the whole thing is here to prevent.

The service group is the one that would have been easy to leave out. A
power:in feed, its power:return, a Kelvin sense tap, a vref in either
direction: none of those is a measurement, and counting them as coverage fills
a test report with pins that prove nothing. They are exempt from
dut.require_all_test_points, so the board says which pads are plumbing instead
of that exemption being hand-edited into every config. Exempt from being
required is not forbidden, though -- measuring your own feed is a reasonable
thing to want -- and making that work turned up that PF040 and PF041 were
asking different questions off one set of signals. What a config may name is
every signal the board brings out; what a complete fixture has to cover is
narrower. They have separate sets now.

Nothing here trusts a label. A pad coordinate is a consequence of the layout
and cannot disagree with the board; a label is an assertion somebody typed and
it can, which is the category of error pinside exists to remove. So every form
in the grammar is comparable against a board fact and the comparing is the
feature. PS047 fires when a pin labelled uart:tx sits on a net called SPI_CLK:
one of the two is wrong, it catches a mislabelled pad and a misnamed net with
the same test, and it demotes classify() from the verdict to a second opinion,
which is a better job for a regex over mnemonics than deciding. PS045 is
PS030's argument generalised -- a fixture cannot close a switch onto a rail it
has no probe on, any more than it can measure against a ground it has no probe
on -- and it is the one that saves a board, because nothing on a schematic
shows it.

A declared gnd now outranks the net name, so a ground pad on a net KiCad
auto-named satisfies PS030 and PS031, which it could not before.
power:return deliberately does not: a return carrying an amp develops a drop
across its own contacts and every measurement referenced to it rides on that
drop, which is why a rail that should read 3.300 V reads 3.26 V and nothing
explains it. PS038 says when a board's only grounds are returns.

Two things differ from what docs/test-point-labels.md specified, both
deliberate and recorded there. PS049 means "some pads are labelled and some
are not" rather than "nothing is labelled": the planned spelling fires on
every board that exists today, test_healthy_board_is_clean asserts the clean
board yields no codes at all, and CONTRIBUTING.md is explicit that a check
firing on boards.healthy() is a false positive by definition. A half-labelled
board is the actionable case -- somebody started and stopped, and the pads
they missed are the ones still being guessed at. And PS054 is new, because the
rule that an unknown key warns rather than refuses needed a code to warn
under and none of the reserved ones fit.

PS036 and PS037 are specified and not implemented. They need a probe's current
rating, and pogo.py records a probe's hole, pad, body, pitch, travel, force
and tip and nothing about what it carries. The user guide says so where
somebody about to trust a check will read it, rather than shipping a check
that cannot fire.

One bug surfaced in the writing, in the part that matters most for a string
typed into a narrow dialog field: the parse error for an unknown subtype
reached its generic message instead of the one listing the valid subtypes,
because the hint branch tested whether the family was itself a role and `uart`
is not -- only uart:tx and its siblings are. `uart:middle` said "see the docs"
where it should have said "uart takes one of: uart:cts, uart:rts, uart:rx,
uart:tx". Its own test caught it.

The reader is tolerant, as everywhere else: a label it cannot parse is kept as
its message and reported as PS046 rather than raised, because refusing a whole
board over one fat-fingered field would make the feature a liability. An
unlabelled board behaves exactly as it did before.

Verified against a board KiCad itself wrote rather than only synthetic ones.
Injecting the property and running `kicad-cli pcb upgrade --force` returns it
intact, promoted to a full field with a uuid, a layer and text effects, and
board.py's existing _property() reads it back unchanged -- so the reader cost
one lookup. The same file run through `pinside check` reports PS046 with the
subtype hint on `uart:middle`, PS054 on an unknown key, PS048 on a reference
to a net the board does not have, and PS049 for the fifteen pads still
unlabelled. The suite is 267 tests from 229, every new check paired with an
assertion that boards.healthy() does not trigger it; labels.py is at 92% and
checks.py at 97%, with the total at 88%.
…alled

scaffold.py read a pin's purpose off its net name. DECLARED_FACING now maps a
declared role to the fixture pin role that must face it, so the inversion that
is the usual first bug in a fixture -- a DUT that transmits has to arrive at
the fixture's receiver -- is read rather than guessed. _group_of takes the bus
kind from the declaration while keeping classify()'s own prefix shape, so
ETH_CLK and DISP_CLK still land on separate peripherals.

The tasklist said tests first, because this file drafts the config everything
else is built on and it went a whole release completely broken. So
TestScaffoldGrouping characterises what the heuristics do today -- buses
separating by leading token, a group with no clock falling through to GPIO, a
rail reaching the ADC with its voltage read off the name, open drain plus
active low as the safe default -- and those passed before anything moved. They
are what proves an unlabelled board still draws the draft it always did.

Three behaviours are new, and one of them was a bug waiting to happen. A
service pin now gets no channel at all: a pad on a supply net previously
became a released open-drain GPIO, which is a fixture arranged to pull down
the rail it is feeding, and the only thing standing between that and a board
was the POWER regex happening to match the net's name. A declared power:rail
reaches the ADC whatever the net is called, and takes nominal and tol off the
label instead of digging digits out of the name with a regex. And a pad
declaring something the fixture has to *source* is left out rather than
guessed at: pinside cannot present an RTD yet, a released open-drain GPIO on a
sensor input is worse than an absence, and PF041 reporting it as uncovered is
the truth until 0.4.0. cmd_init says as much on stderr.

That last message needed fixing anyway. It asked classify() which signals it
had failed to recognise, so a pin the board *declares* as a reset was reported
as one pinside had guessed at -- which is how people learn to ignore a
message.

cli.py goes from 71% to 82% with TestCommandBodies, over the exit statuses and
error paths of init, generate and project, which is the part a script
downstream depends on. Two of those tests were wrong before they were right
and both mistakes were mine, not the code's. PF003 deliberately permits
regenerating into pinside's own output and tells the two cases apart by the
src/fixture_config.c stamp, because regeneration has to stay idempotent to be
usable from a Makefile; I had asserted it should refuse. And --carrier is
argparse-validated by choices, so the command body never sees a bad one, while
--mcu is free-form and is the path `except ValueError` is actually there for.
What is still uncovered wants pyserial and a port, or KiCad's symbol
libraries, and belongs to the CI-invisible paths the container covers.

One tasklist item is re-specified rather than done. "Read net classes, not
just net names" claimed the class is in the .kicad_pcb. It is not: checked
against KiCad 10.0.3, a board KiCad has rewritten carries no net_class at all.
Net classes live in the sibling .kicad_pro as JSON under net_settings, where
classes defines them, netclass_assignments maps a net explicitly and
netclass_patterns does it by glob. So it is not a parser change but a contract
change -- check and generate take one file today and ARCHITECTURE.md states
that as the shape of the tool, the .kicad_pro is frequently not shipped beside
a board, and glob semantics mis-assign quietly. A wrong class is worse than
none, for the same reason a wrong label is. The entry now says all of that and
asks for the decision before any of it is written.

Verified: 300 tests from 283, scripts/lint.sh clean, coverage 90% overall with
scaffold.py at 89% from 74% and cli.py at 82% from 71%. The draft an
unlabelled board produces is byte-for-byte what it was, which is what the
characterisation tests exist to hold.
A new quantity touches a circuit, a parts list, a firmware driver, a set of
computed component values and an accuracy claim. Spread across config.py,
schematic.py, generate.py and a template, the parts that have to agree sit
maximally far apart, so they drift, and a fixture whose schematic says 6.19k
while its firmware assumes 10k reports wrong numbers with total confidence.
pinside.blocks makes that disagreement a diff.

It is the fourth catalogue in the shape of pogo.py, modules.py and targets.py:
a frozen dataclass, a get(), and a test checking the entries against an
outside authority. The authority is KiCad's symbol library, because the
mistake this invites is a module naming a part that does not exist -- ADG1419
and TS5A3157 are the obvious analogue switches for this circuit and KiCad
ships neither, so such a module validates, passes review, and fails on
somebody else's machine. All 22 of the first module's parts are checked
against the real libraries.

Descriptors are JSON because tomllib is 3.11 and this package supports 3.10
with no dependencies, so validation is hand-written. `presents` is enforced
rather than stated: a configurator reports the reach_min/reach_max envelope it
actually built, and a configuration outside the declared range becomes an
error finding. Otherwise the claim would be decoration -- asking the ladder
for 0..900 C produces a working table spanning 405 ohm against a descriptor
claiming 320, and nothing would have said so.

configure() must be pure, and the suite calls every one twice and compares.
pinside promises an unchanged config regenerates byte-identically and that
config_hash is stable; a module answering differently on a second call breaks
both, intermittently, which is the worst way to find out.

The first module is blocks/resistance_ladder: a series string shunted by reed
relay contacts, presenting a PT100 to a DUT's sensor front-end. Descriptor,
configurator and tests are in; driver.c, the sample schematic and the bench
session are not, so the tasklist entry stays open.

One real design error, found while writing it, and the kind that produces a
plausible wrong number rather than a failure. The contact resistance has to be
inside the arithmetic that *chooses* the resistors, not only inside the table
that looks them up. Energising a relay shorts its resistor out and inserts one
contact resistance, so

    total(word) = (base + sum R) - sum over set bits of (R_i - contact)

which makes bit i's effective weight R_i - contact. Sized as plain binary, the
smallest resistor's effective weight collapses from 0.374 to 0.224 ohm while
the largest barely moves: the ratios stop being binary, the achievable totals
stop being contiguous, and the set develops 1.5 ohm holes no lookup can fill.
Sizing each resistor as contact + lsb * 2**i keeps the effective weights
binary and takes quantisation from 1.65 C to 0.70 C. The base resistor rounds
*down* to E96 for the mirror reason: rounded up, the bottom of the range sits
below anything the string can present, and no amount of switching reaches back
down. Both of those are now tests rather than comments.

The honest accuracy is worse than the design note estimated by hand: +/-2.3 C
uncalibrated and +/-1.3 C with the contact resistance measured, against the
+/-1.5 and +/-0.7 written in docs/signal-injection.md. configure() computes it
per range now, so the note carries the computed figures and says they replaced
an estimate. Nobody has put a DMM on any of it, so `verified` is empty and the
descriptor says as much where somebody about to trust a number will read it.

The E96 toolkit started as two copies -- one shared, one inside the
configurator, on the argument that a module loaded by path should not depend
on the package that loaded it. That was wrong twice: a design module is a
pinside module and already runs inside pinside's process, and two copies of a
resistor table is exactly the drift the one-directory rule exists to stop. One
copy now, used, which also took blocks/__init__.py from 74% to 87% by deleting
the dead half.

Packaging needed widening and is now asserted. A design module's descriptor
reaches a wheel only through package-data, and a wheel that kept the
descriptor and lost the configurator would install a catalogue that validates
and cannot be used -- the same failure the firmware-template assertion exists
for. release.yml checks both, and additionally installs the wheel into a clean
environment and confirms a module is discoverable and loadable there, because
discovery scans the installed tree and configurators load by path, so only a
real install proves it.

Verified: 336 tests from 300, lint clean, coverage 90% with the configurator
at 93%. The wheel was built and installed into a clean venv, where
resistance_ladder@1.0.0 is discovered and solves its 251-entry table. The
curve itself is checked against the three values anybody looks up first --
100.00 ohm at 0 C, 138.51 at 100, 194.10 at 250 -- and the emitted table is
checked against the accuracy the module reports for it, in both directions.
… protocol

Everything the fixture did was observational or digital: read a rail, pull a
line, talk a bus. An injected channel presents a quantity instead -- a
resistance where the DUT expects a PT100 -- and the circuit that does so
differs per quantity, so the firmware needs one interface all of them
implement. fixture_inject.h is that interface, in the same spirit as
fixture_hal.h.

The decision the shape rests on: a module does not add RPC methods. The core
owns sim.channels, sim.set, sim.get, sim.verify and sim.fault, and dispatches
by channel to an fx_inject_driver vtable. A protocol that grew with the module
set would mean every new module could break the contract test_contract.py
enforces against the dispatch table, and openrpc.json would never be stable.
The five methods were added once, and adding the next module changes neither
file.

That claim was tested by accident on the way: adding them to fixture_core.c
without adding them to the contract made test_contract fail, the firmware
implementing five methods the document did not declare. That is precisely the
drift it exists to catch, and it caught it.

Values are integers in a unit small enough to be exact -- milliohm, microvolt,
nanoamp, millihertz -- because floating point in generated firmware is a
portability problem and a diff problem. A module may accept a derived unit:
the ladder takes millicelsius and looks the switch word up in a table
Callendar-Van Dusen solved at generation time, where it is testable.

The ladder's driver computes the string exactly the way its configurator does,
and that agreement is the point. If they diverged, the table would be choosing
words against a string that does not exist, and every reading would be
plausible and wrong. The host tests go at the thing a mock can actually prove:
that the switch word reaches the pins, by commanding two different values and
asserting the pin levels differ. A driver that answers without doing anything
looks exactly like a miscalibrated DUT.

One honesty call inside it. verify reports *microvolts* unless the config
declares the DUT's excitation current, because the fixture measures a voltage
and ohms from a guessed current is how a verification step comes to agree with
whatever it was told. The core already refuses to compute an error when the
measured and commanded units differ, so the two fit together.

An injector is structurally a bus rather than a GPIO -- a presented quantity
has a far end and the fixture has to reach it -- so probes maps a role to a
DUT signal the way a bus does. request is design intent and calibration is a
measurement of one physical fixture; the split matters because the first
travels with the test.

Two gaps surfaced in the wiring, both quiet ones.

config_hash did not include injectors. That broke the whole argument for
keeping calibration in the config: putting a DMM across one closed relay and
writing the number down would not have invalidated a flashed fixture, so
pinside probe would have accepted firmware built before you knew. It now
covers the resolved module version -- a MAJOR bump means the board somebody
had made is wrong -- the request, and the calibration, and still ignores prose
so rewording a description does not invalidate a fixture that is still right.

PF023 did not see an injector's switch pins. Ten of them on the first module,
the largest claim any single channel makes, unchecked against the target and
against every other channel. Enough for a relay and a reset line to share a
GPIO, which is a fixture that resets the DUT every time it changes
temperature.

PF043, PF044, PF047, PF048 and PF050 land with the config model. PF048 is
where a module's refusal surfaces: a configurator that cannot meet a request
has to say so rather than return its best effort, because its best effort is a
number somebody will trust. PF050 carries what a module had to say about a
channel it did configure -- the second-order consequences nobody thought to
ask about.

And clang-format now covers the modules' drivers, not only the firmware
templates. Both are copied verbatim into generated projects, so both are the
product; extending the glob immediately caught that the driver I had just
written was unformatted, which is the right way round.

Verified end to end rather than by inspection. 359 tests from 336, lint clean.
A fixture config carrying a PT100 injector generates, and the generated
project's own host suite builds and runs the injection layer, the ladder
driver and the mock HAL together: 83 checks, 0 failures, 15 of them new. A
fixture with no injectors still links and passes its 71, because C has no
zero-length array and the layer ships whether or not it is used. config_hash
was checked for all four behaviours it now has to have: stable on a repeat,
different on a measured calibration, different on a widened range, unchanged
on a reworded description.
A Sheet can now be a child. Three things differ from a flat page and all three
are about identity rather than content: its own document uuid, an instance
path of /root/sheet on every symbol it carries, and no sheet_instances, which
only the root declares. child_sheet() places the reference on the root and
hands back the uuid the child needs; hierarchical_label() is how a net leaves
a page.

The format was read out of a project KiCad itself wrote rather than guessed
at, the same way the Pinside footprint property was. That is what settled the
instance path being /root/sheet and the child declaring no sheet_instances.

An injected channel gets a page carrying its module's real parts with the
values configure() solved, laid out per *quantity* rather than per module --
any resistance ladder is a base resistor and a chain of stages, so a second
resistance module inherits the arrangement. Connections are by net label on
the pin, the same trade the root page already makes.

Three things went wrong on the way. Each failed differently and none of them
was visible without running KiCad, which is the argument for the test that now
does.

The injector's terminals had no probes at all. channel_slots() iterated gpio,
adc and buses, so a sheet pin connected to nothing and ERC said so. Both
terminals of a presented quantity need a pogo probe: the fixture has to reach
the far end, not only the driven side. That was a real gap in the config
model's reach, not a schematic detail.

A hierarchical label parked on empty paper is a dangling label. The far
terminal's label now sits on the last resistor's own pin, so the net leaves
the page from something rather than from nowhere.

And reference designators are unique across a project, not a page. The child
started its resistors at R1 and collided with the root's series resistors, one
per probed channel. That surfaces as "schematic has annotation errors" on
netlist export rather than as a clash, so the netlist came back without any
net names and the first reading of it was that the hierarchy had not
connected. Page 2 owns R2xx now, and the root keeps the low numbers it cannot
run out of.

The relays are deliberately not placed, and this is the part worth arguing.
KiCad's reed symbol Relay:DIPxx-1Axx-11x carries six unnamed pins in a single
unit -- 1, 2, 6, 7, 8 and 14, where 1/14 and 7/8 are package alternates -- and
nothing in the symbol associates a pin with the coil rather than the contact.
The sub-units do not help: all six pins sit in one, and the coil rectangle is
in a graphics-only block with no pins attached to it. Guessing would put a
coil across the measurement string and short it the moment the fixture
energises a relay: a schematic that validates, looks right, and destroys the
reading. Relay:ADW11 does name its coil A1/A2, but it is a mechanical relay
and reed was chosen precisely because a sealed contact switches 1 mA dry
without a wetting current, so swapping the part to make the drawing easier
would be the wrong trade. The page therefore carries the part of the circuit
the library actually states, and says on its face what it is missing. The
hand-authored reference sheet stays the open item it already was.

Verified the only way that counts. kicad-cli sch erc reports 0 errors and 0
warnings, and walks both pages -- "Sheet /" and "Sheet /dut_rtd/" -- which is
the proof the hierarchy is real rather than two loose files. The exported
netlist shows PROBE_DUT_RTD_HI on both R200 pin 1, the base resistor on the
child, and TP15 pin 1, its pogo probe on the root; LADDER0 joining R200 to
R201 and LADDER7 joining R207 to R208, so the string is continuous; and
PROBE_DUT_RTD_LO reaching the far probe. Both checks are tests now, including
the assertion that "annotation errors" does not appear, because that is the
shape the designator clash took.

361 tests from 359, lint clean, coverage 90%. One of the new tests had to have
its relay pins written out by hand rather than as range(11, 21), because 16
and 17 belong to the demo fixture's SPI -- PF023, added earlier today, caught
it.
A review of the branch turned up eleven things. Four were defects rather than
polish, and this is the one worth remembering.

configure() decides how many switches a module wants -- from the pin budget, a
measured resistor string, or a calibration override -- and the config decides
which pins it may use. Nothing reconciled the two. A config with
calibration.relays of 12 and the documented ten pins validated with a single
*info* finding and then emitted relay_count = 12 beside a ten-entry pin array.
ladder_init and apply_word loop i < relay_count over relay_pins[i], so the
firmware read past the end of a const array in flash and called
fx_hal_gpio_set on whatever byte it found: a fixture configuring and driving a
GPIO nobody chose. requires.gpio cannot catch it, because the override happens
after that check runs.

PF044 now compares the count the module actually chose against the pins given,
and emit_inject_c refuses as well, so a path that skipped validation still
cannot write the struct.

It was invisible to the suite for a structural reason, and that is the more
useful half. The only test that compiles the generated C drafts its config
with scaffold, which never produces an inject entry -- so fx_inject_count was
always zero, test_injection took its early-return branch every time, and
neither driver.c nor the generated parameter block nor the sim.* handlers was
ever built in CI. The generated host suite already compiles with
-fsanitize=address and reports this immediately once there is an injector to
report about. That test now builds one, which makes the class of bug visible
rather than this instance of it.

Two of the label checks made a correctly labelled board worse off, which is
the opposite of what the feature is for. Both were one predicate answering two
different questions.

A key the parser reported as ignored was still treated as a live net
reference, so `gnd ref=RTD_LO` warned under PS054 that ref was being thrown
away and raised PS045 at error severity about the net it named -- failing
pinside check over a line the tool had just said it was not reading. A
reference is now live only for a key the role accepts.

And is_ground read a label as gnd-only, so labelling a board's grounds
power:return -- which the user guide presents as the correct label -- turned
PS030 on as an error, blocked pinside project on a board that had been clean
before anybody labelled it, and added PS042 noise besides. A return is a
return path, and the checks that ask whether one exists want that answer;
whether a measurement may be *referenced* to it is narrower, and PS038 is what
reports it. is_measurement_ground is that narrower question, and the test that
had encoded the old behaviour now asserts both halves.

Degenerate config values crashed validate() with a ZeroDivisionError instead
of the PF048 the surrounding docstring promises -- step_milli_c of 0 and
relays of 0 both divided by zero. The module refuses them now, and the check
catches arithmetic errors as well as ValueError.

The rest:

A module's base resistor was sized against the switch count from before a
measured resistor string overrode it, so handing it a ten-resistor string left
the base compensating for eight closed contacts. It surfaced only indirectly,
as an error pointing at the base resistor rather than at the mismatch.

PF051 is new, for a module presenting a quantity the emitter cannot write a
parameter block for. The driver vtable is generic and the emitter is not --
each quantity's parameters are a different struct declared by its own module
-- and the second module in the catalogue would have made generate() die on a
KeyError rather than report anything. The gap is now named instead of hidden.

PS055 is new, for a presented channel naming no reference at all. PS045
catches a reference pointing somewhere unreachable; this catches one never
written, which is the same "the fixture has to reach both ends" failure by
omission.

The generated fixture README documents injected channels now: the module and
version, the solved component values, the computed accuracy, and whether
anybody has measured the circuit. It was the one output that never received
the configurator's result, which rather defeated Block.verified existing so
that the place a number is read is the place that says nobody checked it.

PS029's plate coefficient skipped its own last tabulated aspect ratio, so a
board at exactly 4:1 reported about 1.5% more bow than one at 3.99:1 -- a
discontinuity in the one number that check quotes.

PF047 had two meanings in one branch: reserved in the design note as "no
calibration record" and implemented as "no verify channel". The implemented
one stands, because an uncalibrated channel still reports a number somebody
can sanity-check while an unverified one reports only what it was told; a
missing calibration arrives as PF050, the module saying so itself. The note
says so.

Verified on all four paths: 369 tests from 361, lint clean, the generated host
suite at 83 checks with 0 failures, kicad-cli sch erc at 0 violations across
both pages, and the portable firmware cross-compiling clean for Cortex-M33 at
-Wall -Wextra -Werror -Wconversion, which is stricter than the host suite's
own flags.
…claimed

blocks/contact_relay presents a dry contact between two probes with one reed
relay, and forces open and closed on command. It is in the catalogue to answer
"does adding a module cost what the architecture says it costs" with something
other than an argument: a different quantity, a different unit, a different
parameter struct, almost no arithmetic. What it exercises is the contract.

It needs no new HAL functions either -- one fx_hal_gpio_set and fx_hal_millis
for the settle wait. A reed closes in well under a millisecond and bounces for
a little after, and returning before it has settled means a DUT read
immediately afterwards sees whichever half of the bounce it landed in, which
looks like an intermittent DUT rather than an impatient fixture.

Adding it forced the firmware emitter to stop being hardcoded to one module,
which the review had flagged and the previous commit only guarded against. A
module now declares its struct and its fields in module.json, in the order its
own driver.h declares them, and the generator fills it positionally from
configure()'s output. Drift between the two declarations is a compile error,
which is a better guard than a validator describing the struct would be: the
compiler checks the struct that exists rather than a description of it, and
the suite builds a fixture with an injector in it.

A field may also declare that it `counts` an array, and the emitter then
checks the two against each other. That is the invariant whose absence was the
buffer overflow: relay_count and relay_pins are now declared as a pair, and so
are word_count and words. Restoring it generically was necessary because
making the emitter generic had quietly dropped the hand-written check along
with the hand-written struct -- the test for it caught that immediately, which
is the argument for having written the test.

Two more things came out of the wiring.

Validation and generation were configuring a channel with different contexts.
The checks passed the calibration; the generator passed the calibration plus
the pins. So a config could validate against one answer and generate from
another, and the pin/switch reconciliation that caused the overflow was
happening in only one of the two. InjectChannel.context() is the single source
now and all three call sites -- checks, firmware, schematic -- use it. Which
pins a module's switches sit on is a fact about the fixture, so handing them to
the configurator is also what lets a module refuse a pin list that cannot
cover its own switches, rather than the generator reconciling two numbers it
only half understands.

The reference probe was read as a fixed `lo` or `ref`. That worked for the
ladder and silently reported no reference at all for a module that calls its
terminals a and b -- the sort of thing that shows up as a blank column in a
report and nowhere else. It is the second role the module declares, whatever
it names it.

A module page with no layout strategy no longer puts terminals on its sheet. A
sheet pin whose hierarchical label sits on empty paper is a dangling label,
and ERC saying so would be saying something true. The page documents the
circuit instead and says where its terminals actually are, which is the root
page, where their probes have been all along. PK005 says the same thing when
the project is written, because hearing it then beats discovering it in ERC and
wondering whether the generator is broken.

The fault modes mean what they say for a switch: open is a contact that will
not make, short is one that will not break. That is not redundant with set --
a contact stuck in the wrong state is a different test from one that works,
and a welded contact is what actually happens when a reed is overloaded. So
configure() compares the declared load against the contact's rating and says
that an overloaded reed welds *closed*, failing as a permanent short rather
than an open, which is the opposite of what a test engineer would assume.

PF024 is worth a note for having worked unprompted: the first pin I picked for
the contact coil was GPIO23, and the carrier-board check named it as
lid_switch.0 and refused, because the Pico 2 consumes 23 for its regulator
mode and does not bring it to the header.

Verified on four paths with both modules configured at once: 377 tests from
369, lint clean, the generated host suite at 83 checks and 0 failures with two
drivers and two differently-shaped parameter structs linked in, kicad-cli sch
erc at 0 errors across all three pages, and all five portable C files
cross-compiling clean for Cortex-M33 at -Wall -Wextra -Werror -Wconversion.
The two ERC warnings that remain are the undrawn contact circuit's probes
reaching one pin, which is what PK005 exists to say.
…ch can read it

PF046 is the check injection made necessary. Every other channel the fixture
has is an observer or a pull: the worst two of them can do to each other is
argue about a line neither is driving hard. An injector sources, so a second
channel on the same net is two sources across one pair of probes, and what
they measure is each other.

It earned its keep before it had a test. It fired immediately on two of this
repository's own test configs and on the scratch fixture I had been generating
all session -- each of which genuinely put an injector and a GPIO channel on
one net, because the demo fixture already watches +3.3V and PWR_FLT and I had
pointed an RTD at them without noticing. Declaring the overlap in `shares` is
the escape hatch, and it is meant to be awkward.

One pairing is exempt and it matters that it is: an ADC named as the
injector's own verify channel is there to watch it, which is the entire
argument for pairing them. A check that made the recommended arrangement an
error would be worse than no check.

PF049 goes in before the module that can trip it, which is the order that
matters for a guard. A resistance or a contact has no voltage of its own --
what the DUT puts across one is the DUT's business -- so it takes a module
presenting volts to exceed the logic rail, and this is what stands between a
24 V loop supply and a 3V3 pin. Tested against a fabricated voltage block,
because the catalogue has no real one yet.

PK004 tells "that module names a symbol nobody has" apart from PK003's
"install KiCad". It checks a module's parts before the sheet is built, so the
message names the module and the part rather than reporting a missing library.
That is the failure ADG1419 and TS5A3157 invite -- both the obvious analogue
switches for this circuit, neither shipped by KiCad.

PF045 is retired unused. It was reserved for "an injector probe naming a
signal the board does not have", and probe_names() now includes injector
probes, so PF040 already says exactly that. The number is burnt rather than
reused, per CONTRIBUTING.md.

The module versions are on the silkscreen now, which completes the three
places. config_hash already makes a bump refuse stale firmware and
fixture.info already reports it over the wire; a board on a bench was the one
artefact carrying no metadata at all. A MAJOR bump means that board is wrong,
so the version has to be legible on the thing somebody is holding.

And `pinside init` drafts the ADC that watches a presented channel. Drafting
the injector itself needs a module, a pin budget and a range -- a decision
rather than a guess, and guessing a released open-drain GPIO onto a sensor
input would be worse than leaving it uncovered. The ADC across the same pad is
neither a decision nor a guess. It is the half people skip, and an injector
with nothing measuring it reports only what it was told.

Verified: 384 tests from 377, lint clean, the generated host suite at 83
checks and 0 failures with both modules linked in, and kicad-cli sch erc at 0
errors across all three pages of a two-module project whose silkscreen now
reads "dut_rtd: resistance_ladder@1.0.0 | lid_switch: contact_relay@1.0.0".
…ecified it

blocks/voltage_dac is an MCP4921 against a REF3030 behind an MCP6002 buffer,
scaled to whatever range the DUT's input expects. It is the module the
configure() contract was designed around: a 12-bit DAC against a fixed
reference produces one range, the DUT expects another, and the resistors that
bridge them depend on both, so they cannot be values printed in a descriptor.

The tasklist said this module cost one new HAL function. It costs none. The
fixture already masters SPI, so the DAC rides the bus it has and its chip
select is a GPIO. That was worth checking before designing anything, because
CONTRIBUTING.md prices a second microcontroller vendor at 17 HAL functions and
the cheapest time to notice is before writing the driver.

It then corrected its own design note. ARCHITECTURE.md's worked example named
6.19 k over 2.00 k by hand, giving 1.00024 V and 1.51 kohm of source
impedance. The search finds 5.11 k over 1.65 k: 0.97 of one code low, and
1.25 kohm.

*How* it finds better is the part worth keeping. Minimising the ratio error is
the obvious rule and the wrong one. The DAC resolves one part in 4095 and the
reference contributes 0.2%, so a ratio error smaller than a single code cannot
be observed and buying more of it buys nothing. So the search takes every pair
landing within one code and among those picks the lowest source impedance --
which is the term that actually bites, because a divider and a DUT input form
a second divider and the reading goes low with nothing in the schematic
looking wrong. My first implementation minimised ratio error alone, found a
pair 40 times more accurate on ratio and three times worse on impedance, and
was wrong for exactly that reason.

That is also why configure() reports the loading error against a declared
dut_input_ohm, and reports the impedance needed to stay inside 1% when none is
declared. The second is a number somebody can check against a datasheet in a
minute rather than a threshold they have to derive, and a hand-built divider
gives the same voltage and no warning at all.

The faults are real rather than approximated. The MCP4921's /SHDN puts the
output into a genuine high-impedance state, so "sensor disconnected" is
something this module presents rather than imitates, and a short is code zero.

Two defects came out of wiring it up.

PF049 judged the wrong number. It compared a module's declared *envelope*
against the fixture's logic voltage, so a voltage module whose descriptor says
it can be built up to 12 V was refused on a 3V3 fixture even when the channel
was configured for 1 V -- that is, every safe use of one. It judges the
configured reach now. The envelope says what the circuit can be built to; the
config says what this build does, and only the second can be judged. The check
was written one commit before any module could exercise it, which is the right
order for a guard and also why the bug survived: nothing could trip it.

And MCP6002 is not a symbol KiCad ships. My earlier check grepped for a
*prefix* and matched MCP6002-xMC, -xMS, -xP and -xSN, none of which is
"MCP6002". The module test resolves every lib_id exactly and refused the
descriptor on the first run -- which is the failure that test exists for,
caught by the mechanism built for it rather than by somebody's machine.

An inject channel may now name a bus, for a module whose descriptor asks for
one. It is checked against the config's own channels and its peripheral index
reaches the module through the context that validation and generation share.

Verified with all three modules configured at once: 394 tests from 384, lint
clean, the generated host suite at 83 checks and 0 failures with three drivers
and three differently-shaped parameter structs linked in, kicad-cli sch erc at
0 errors across four pages, and all six portable C files cross-compiling clean
for Cortex-M33 at -Wall -Wextra -Werror -Wconversion.
blocks/frequency_pwm drives a square wave from the controller's own PWM
through an open-drain stage, so the line sits on the DUT's pull-up -- which is
what a hall sensor or a flow meter output looks like, and what makes the two
fault modes free. The stage inverts everything: a stopped PWM releases the
line, which is an open sensor, and a continuously driven gate pulls it down,
which is a short. No extra parts for either.

The tasklist called this module "nearly free". It was wrong about that, and
the way it was wrong is worth recording. A resistance is relays on GPIO, a
contact is one relay, a voltage is a DAC on the SPI bus the fixture already
masters -- all three went in without touching the HAL. A frequency has no such
route. A software loop would jitter with whatever else the core is doing and
would block the protocol while it ran, so there is no honest way to synthesise
one.

So fx_hal_pwm_set went in, and the HAL is 18 functions where it was 17. That
number is claimed in four places and all four now say 18, because an
increment nobody wrote down is how a contract priced at 17 functions quietly
becomes one priced at 30.

It returns the rate the hardware *achieved* rather than taking the one it was
given. That is the whole design of it: a PWM peripheral counts a divided
clock, so what it can produce is clk/(div * period) for integer div and
period, and the difference between the commanded and the produced rate is
exactly what a tacho test is measuring. A driver that reported the command
back would hide it.

The arithmetic is the part nobody reads off a datasheet, and configure()
computes it for the range actually asked for rather than quoting general
facts.

There is a floor. The divider and the counter are both finite, so the slowest
wave is clk/(div_max * period_max) -- about 8.98 Hz on an RP2350 at 150 MHz.
A flow meter idling at 1 Hz is below it, and the range is refused with the
number in the message rather than producing something unlike what was asked.
The test checks the implementation against clk/(255*65536) independently,
which is how I found that my first test constant was wrong and the code was
right.

And the spacing grows with the square of the rate: under a hertz at 10 kHz,
6.7 kHz by 1 MHz, which is 0.67% and starts to matter. A coarse top of range
is a warning rather than a surprise.

The mock stays dumb on purpose. It records what it was asked for and returns
it unchanged unless a test sets pwm_achieved_mhz, because quantising in the
mock would mean a second copy of the divider arithmetic drifting away from the
real one. Letting a test state the answer keeps the mock honest and the test
in control.

One incidental result worth keeping: four modules alongside the demo fixture's
own channels exhausts a Pico 2's 26 header GPIO. PF023 caught the collision
and PF025 said there was one pin left, and `bare` is what the README already
documents for exactly this. The four-module fixture is built on a bare
rp2350b for that reason, which also moved its ADC channel to pin 40 where that
part's converter actually is.

Verified with all four modules at once: 404 tests from 394, lint clean, the
generated host suite at 83 checks and 0 failures with four drivers and four
differently-shaped parameter structs linked in, kicad-cli sch erc at 0 errors
across five pages, and all seven portable C files cross-compiling clean for
Cortex-M33 at -Wall -Wextra -Werror -Wconversion. The twenty ERC warnings are
all isolated_pin_label: six from the three undrawn module circuits, which
PK005 names, and the rest from a bare target having no controller symbol,
which the README already says is hand-work.
…ngs by doing it

The tasklist asked for a recorded transcript: capture one real session against
a flashed board and replay it, so the client is pinned to the firmware's
actual bytes rather than to a second implementation of what they should be.

test/replay.c does the same job better. fixture_core.c carries no SDK headers
precisely so it can be built on a host -- the suite already does that to run
the firmware's own assertions -- so this points the same binary the other way:
JSON-RPC lines in on stdin, replies out on stdout. The JSON writer, the
dispatch table and the generated channel tables are the same code and the same
data that run on the microcontroller; only the hardware underneath is a
double. A recording from a flashed board would be closer to real silicon and
would go stale the first time the protocol changed. This is rebuilt from the
templates every run and cannot.

It paid for itself immediately, with three defects FakePort could not have
found, because FakePort is a second implementation that agrees with the client
by construction.

sim.get always overwrote the unit with the channel's own. So a resistance
ladder commanded in millicelsius always read back as resistance, and the
driver's "answer in the unit it was asked in" branch -- which is written,
commented and tested nowhere -- was unreachable. The core now passes
FX_UNIT_NONE unless a caller names a unit, which is what makes that branch
mean something.

Fixture.stream called pop(0) on a deque, which raises. Invisible because every
other test drained the queue with poll first, and connect() files the ready
notification while it waits for fixture.info -- so the queue is never empty
when stream is first called in anger. There is a test for that path now.

And the port double blocked forever where a real serial port with a timeout
returns empty. The client is written against the second behaviour: _read_line
treats an empty read as "nothing yet" and gives up at its deadline. A pipe
does not, so the first version of the double hung the suite rather than
failing it. It uses a selector now, which is the behaviour it was pretending
to have.

The end-to-end assertion is the one worth having. Callendar-Van Dusen in
Python, solved into a switch-word table at generation time, compiled into the
driver, commanded over the protocol, and read back as the resistance the
string actually presents. A PT100 at 100 C is 138.51 ohm; the fixture answers
138.428, which is 0.21 C and inside the quantisation budget the module
computed for itself. That number has been asserted in three places separately
until now and never once end to end.

probe --watch is built on a new Fixture.stream rather than on poll. poll waits
out its whole window and hands everything back at once, and a log line that
arrived in the first second should reach the person reading it in the first
second. A streamed UART's bytes print as text, which is what somebody watching
a boot log wants.

And probe reports the injected channels: the module and version behind each,
its unit, and whether anything is watching it. The client had no sim_* methods
at all -- 0.4.0's own cost table promised sim_set, sim_get and sim_fault and
they were never written, so the whole injection surface existed in the
firmware and was unreachable from the host. All five are there now, and an
older firmware with no sim.* at all is not an error to probe.

Verified: 414 tests from 404, lint clean. Ten of the new tests drive a real
generated fixture through its own compiled protocol core, including the hash
check -- which is the one thing FakePort cannot establish, since it computes
the digest the same way the generator does and therefore agrees by
construction.
…and a pad to leave alone

Three requests that turned out to be one feature from three sides. A fixture
does not have to carry electronics. Strip them out and what is left is a
wiring harness with precise geometry -- which is the hard part, and the part
this tool was always for.

A direct fixture. fixture.mode "direct" gives pogo pins out to a connector and
nothing else: no controller, no series resistors, no firmware. It has no
channels by design, which removes the pin budget and the allocation along with
them -- it probes whatever the DUT brings out, and the connector's way count is
the limit instead. pinside generate refuses, because whatever measures those
probes is on the other end of the connector and is not pinside's to write.

The pin map is the product. Everything else here is a board somebody sends to
a fab; the map is the part a human reads while wiring the other end, and the
only thing standing between a correct harness and a correct-looking one. It
goes in the generated README, assigned from the probe order so that adding a
test point does not renumber what is already wired, and generated from the
same assignment the schematic used rather than described alongside it.

connectors.py is the catalogue, in the shape of pogo.py. The 78-way
high-density D-sub that was asked for has a real 78-pin symbol in KiCad and no
footprint at all: Connector_Dsub stops at DSUB-37 and the high-density family
runs on to DD-78. So the schematic, the netlist and the pin map are complete
and the footprint is left unassigned, with the reason on the schematic and in
the README. A wrong footprint on a 78-pin connector is a board that cannot be
salvaged, and inventing pad coordinates for a part nobody here has held is how
that happens. header_78 is the same 78 ways, complete today, and ribbon to a
breakout is an ordinary way to reach a switch matrix.

The guide plate. A bed-of-nails is two plates and pinside only built one. The
probe plate carries the receptacles and the copper; the guide plate sits above
it on the same standoffs with a close-fitting hole for every pin, holding each
perpendicular near its tip. A pin leaning one degree over 10 mm of exposed
length has moved 0.17 mm where it lands, which is most of the placement budget
on a small pad -- and it reads on the bench as an intermittent channel rather
than as anything mechanical. The plate is a drilled sheet: no copper, unplated
holes, so it panels beside the probe plate for almost nothing.

--guide-hole is required and that is the point. The hole has to fit the spring
pin; pogo.py records the receptacle -- its press-fit hole, its pad, its body --
and those are different diameters. A hole sized on the receptacle's would pass
the pin with half a millimetre to spare and guide nothing. The chain of
reasoning from a press-fit dimension to a plunger diameter is exactly the sort
of inference that puts a wrong number on a board somebody orders, so PK006
refuses and says to measure one pin with a micrometer. guide_dia_mm exists in
the catalogue and is deliberately empty.

And `ignore` now has a stated guarantee rather than an emergent one. It
already kept the fixture off a pad, by omission: an ignored pad reaches no
channel, so init drafts nothing and no probe is placed. That is three separate
behaviours happening to agree. It is stated and tested now, in both modes, and
in direct mode it is the only thing between "the DUT has a pad there" and
"drill a hole and press a receptacle into it". PF052 reports the contradiction
a hand-written config can still create.

Two defects fell out of the work, both found by KiCad rather than by the suite.

library.py could not find most symbols in a large library. _find_block checked
nesting depth by counting parentheses -- including the ones inside quoted
strings, and KiCad's own descriptions are full of them: "02x39, odd/even pin
numbering scheme (row1 odd, row2 even)". The tally drifts further out with
every entry it passes, reading -37 by the time it reaches Conn_02x39, so every
symbol past the first parenthesised description in Connector_Generic was
unloadable. _matching already skipped string bodies correctly; the search did
not.

And a probe found its DUT pad by signal. Every ground shares a signal, so two
ground slots resolved to the same pad: two receptacles stacked in one hole and
the other ground left unprobed. DRC called it holes_co_located and nothing in
the suite had an opinion. A slot carries its pad's reference designator now,
which also fixed the guide plate, since it had inherited the same lookup.

Also: project reported "0 probes placed" on a direct board carrying sixteen,
because the count came from the config's channels.

Verified: 449 tests from 414, lint clean. A direct fixture with a 78-way
connector and a guide plate generates, and KiCad accepts all of it -- ERC 0
errors, the netlist showing every probe reaching both its pad and its
connector way, DRC free of co-located holes and shorts on the probe plate, and
the guide plate resolving its own footprints. The 78-way D-sub's unassigned
footprint is the one thing left for a human, and it says so.
KiCad ships the high-density D-sub family at 15, 26, 44 and 62 ways and not at
78, so this one is drawn from the part's dimensions into the project's own
library -- the same arrangement the pogo receptacle and the guide hole already
use. dsub_78 is complete now, and PF055 no longer fires on it.

Four rows of 20/19/20/19, a 2.41 mm contact pitch, rows 2.08 mm apart, 1.05 mm
drills and jackscrews 61 mm apart. The row structure is the part worth saying
out loud: a DC-62 is three rows and a DD-78 is four, so deriving the larger
from the smaller -- which is what I would have done with the geometry I had --
produces a footprint wrong in every pad, on a board nothing would flag until
it came back from the fab. That is what the refusal to infer it was protecting
against.

The dimensions came off the part's drawing. They were checked pad-for-pad
against an independently written footprint for the same connector and agree to
0.005 mm, which is that file's own rounding. The numbers are kept as numbers
rather than as a vendored file: that repository carries no licence and no
readme, so there is no grant to redistribute it in an MIT package, and a
measurement is a fact where a file is an expression. The 2.41 mm pitch is
corroborated independently by KiCad's own DSUB-62-HD, which uses the same
contact system.

Also dsub_62, which is the largest D-sub KiCad ships complete and worth
reaching for before drawing anything: 62 high-density ways, symbol and
footprint both present, and more signal ways than most fixtures need.

And a correction. connectors.py said KiCad's Connector_Dsub library stops at
DSUB-37. It does not -- it goes to DSUB-62-HD, and my earlier search missed
the family because the naming is -HD rather than HighDensity. The 78 is
genuinely absent, so the conclusion held, but the stated reason was wrong and
it was wrong in the shipped source.

Verified: 460 tests from 449, lint clean. KiCad parses the generated footprint,
renders all 78 pads to SVG, carries the reference through to the netlist, and
reports ERC clean on a harness that uses it.
The two open 0.5.0 items were the same question asked in two directions: how
much room does a component actually take. PS024 and PS027 answered it with the
bounding box of a footprint's pads, which is the wrong shape in X/Y and no
shape at all in Z.

The courtyard is the X/Y answer, and it cost no new parser. gr_* on Edge.Cuts
and fp_* on a courtyard are the same five shapes under two prefixes, so
read_outline's shape block became _shape_geometry and now serves both;
rotation and offset come from the convention the pads already use, which is
why a part rotated 37 degrees needs no special case. geometry.Area holds the
rings, with contains and distance_to shaped exactly like BBox's so the checks
never learn which kind they were handed. An unclosed courtyard falls back to
its own extent rather than to the pads -- strokes that never met are still
evidence of how much room the part takes, and falling back there would
reintroduce the under-measurement this is for. Both findings now name their
evidence, "TP1 in U1 (courtyard)" or "(pads)", because a clearance measured
off pads can only be optimistic and nothing else in the report says so.

The height is the Z answer, and the entry predicting PS027 was the check that
needed fixing had it half right. PS027 was over-reporting: the receptacle body
is the fat part of the stack and it sits at the fixture's surface, a gap below
the DUT's face, so a 0.9 mm part has only the spring pin's barrel beside it.
What was missing entirely is the other half. A part taller than the gap meets
the plate, which spans the whole board, so it collides a long way from any
probe and no probe position helps -- on a bench that reads as every channel
being intermittent at once, which is the one symptom that looks like a
firmware fault and is not. That is PS056, and it is an error. PS057 turns
declared heights into the number standoffs are chosen by, which the generated
fixture README now states where it used to say "allow travel plus the board".

A board file does not carry a height -- it is in the 3D model reference and
nowhere a checker can read it -- so dut.component_heights declares it, keyed
by reference or by a glob matched against the reference and the footprint's
library id, with --height REF=MM for a bench run that has no config yet. A
reference beats a pattern and the tallest pattern wins: several can match one
part, dict order is not a specification, and taking the lower number is wrong
in the direction that breaks a plate.

Nothing fires until both halves are declared, which is the part I would defend
hardest. A height cannot be wrong on its own; it is the gap it has to fit into
that makes it too much, and the gap is the standoff length -- the builder's
choice, in no file pinside reads. So fixture.clearance_mm is declared or the
planar rule stands, and a board that says nothing about its parts reports
exactly what it did before. A guessed gap would either invent collisions or
excuse real ones, and both are worse than a check that admits what it does not
know. PF056 reports what a declaration cannot mean, and the case worth having
is a key matching no footprint: a typo'd reference declares the height of
nothing and silently leaves every check as it was.

One number stayed unrecorded on purpose. Limits.probe_barrel is what stands
beside a short part, and the catalogue has it for no probe -- guide_dia_mm is
deliberately None until a supplier drawing says otherwise -- so it is nought
and a short part draws no finding rather than one measured against a guess.
Verifying the catalogue, which 0.4.0 still owes, makes that rule live without
touching this code.

Verified: 503 tests from 460, lint clean. The shipped demo board draws no
courtyards and declares no heights, and reports exactly what it did before,
which is the point. PS056 refuses a project end to end through pinside
project, and the generated README carries the gap its parts need.
Groundwork for the py.typed entry in the tasklist's repository-health
section. Nothing type-checks this package today, so the annotations are
decoration; this is the half that has to be true before a checker goes into
scripts/lint.sh, which is still to do.

mypy reported 31 errors across seven files. Most were narrowing the reader
cannot do for itself -- a slice of a coordinate list is a tuple of unknown
length where every geometry function takes a pair, so board.py now says that
once in _point() rather than at each call -- or loop variables reused for a
second type in the same function, which is how schematic.py came to label the
module's power pins with the *signal* net variable still in scope. That one
was latent: rail_net was computed, checked, and then net was labelled, so the
rails were wired from whatever the previous loop left behind. The tests did
not catch it because they assert the rails are labelled, not which net
reaches them.

One was a real crash path. _check_pins builds PF021's message from
lookup(pin)[0], and the uart/spi/i2c lookups answer None for a pin the target
does not have. The guard above skips those, so the index is unreachable today
-- but it is unreachable by an invariant held two statements away in another
function, which is exactly the kind of thing that stops being true quietly.
It narrows explicitly now.

What is left for the entry: the [tool.mypy] block, the ensure_mypy helper
beside ensure_ruff, and the lint.sh step. The level chosen is
check-untyped-defs with the redundant-cast, unused-ignore, implicit-optional,
strict-equality and unreachable warnings, which passes clean on this tree.
Full --strict is 219 findings away, almost all of it missing generic
parameters and unannotated private helpers, and that is a separate decision
rather than a prerequisite.

Verified: 503 tests, lint clean, and mypy at the level above reports no issues
across all 33 source files.
Finishes the py.typed entry: mypy is pinned in pyproject's dev extra, installed
by the same helper as ruff, and runs in scripts/lint.sh over src/pinside only.
py.typed is a promise to whoever imports the package; the suite that drives it
makes no such promise, and checking it would have bought findings in test code
in exchange for noise in the one place the promise is made.

The level is check_untyped_defs with the redundant-cast, unused-ignore,
implicit-optional, strict-equality and unreachable warnings. The bodies hold the
bugs. disallow_untyped_defs would have meant annotating every private helper
before the first real finding, and --strict is 219 findings away, almost all of
it missing generic parameters -- a separate decision, not a prerequisite.

python_version = "3.10" is the setting that earned its keep. Against the
interpreter that happened to be running, the tree was already clean. Against the
floor in requires-python it found cmd_init calling
Path.relative_to(walk_up=True), which arrived in 3.12. The TypeError that raises
on 3.10 and 3.11 fell into an `except (ValueError, TypeError)` that reads as
deliberate, so on two of the four interpreters CI tests, every drafted config
recorded an absolute board path instead of the relative one the branch exists to
write -- the feature off, silently, for anyone on an older Python. os.path.relpath
does the same job on every version. Two tests read the recorded path back now,
which is what was missing: the suite asserted the command exited zero and never
looked at the value.

Three smaller things came with it. ensure_ruff and ensure_coverage were the same
function twice, so a third copy for mypy became ensure_pinned, with ruff keeping
its $RUFF override. The mypy step names its config file and cache dir
absolutely, because lint.sh is run from wherever and mypy's config discovery
starts at the caller's directory. And .mypy_cache is ignored.

Verified: 505 tests, lint clean including the new step, and the step fails the
script when mypy does.
…eld nothing reads

docs/signal-injection.md was written before the plan slipped a release, and its
numbering never moved: it schedules resistance at 0.3.0 and 4--20 mA at 0.5.0,
where the tasklist has them at 0.4.0 and 0.6.0. Six references, all rebased onto
what shipped, with a line at the sequencing section saying the numbers were
rebased and that TASKLIST.md is the authority if the two drift again.

Two of them were more than a number. The HAL paragraph predicted the DAC
snippet would spend the one new function; what happened is that voltage_dac
needs none -- it talks SPI, which the HAL already had -- and frequency_pwm spent
fx_hal_pwm_set instead. The count was right and the snippet was wrong, which is
worth recording rather than quietly renumbering. And the note lists PF049 as
0.6.0 work when the check shipped in 0.4.0 against a fabricated voltage block;
what is left is making it face a module that can trip it, so it says that.

The new 0.6.0 entry is requires.supply_mv, which four modules declare and
nothing reads -- parsed into Requires and consulted by no check, configurator or
generator. The consequence is already in the tree: voltage_dac declares
presents.max 12 V beside supply_mv 5000, and range_v [0, 10] solves a gain stage
of 3.33 reaching 9.98 V with only an info finding about E96 and a warning about
the reference. The buffer is an MCP6002 on 5 V, so the output clips under 5 V and
the top half of the commanded range reads flat.

PF049 is not that check: it compares the configured reach against
dut.logic_voltage, which protects the DUT. Give a DUT a 10 V rail and it goes
quiet while the hardware still clips. The entry asks for PF057, for the fixture
to declare which rails it has, and for voltage_dac's descriptor to pick a side --
and it goes first in 0.6.0, because the current sink is the first module whose
supply is not the fixture's own, and a field that means nothing is worse than no
field once a 24 V rail is what it would have described.

Documentation and planning only; no source changes. Verified: 505 tests, lint
clean, test_docs green.
requires.supply_mv was declared by four modules and read by nothing, and the
consequence was already in the tree. voltage_dac advertised presents.max 12 V
beside a 5 V supply, so range_v [0, 10] solved a gain stage of 3.33 -- R1 2.49k,
R2 1.07k, reaching 9.98 V -- and returned an info finding about E96 and a warning
about the reference. The buffer is an MCP6002 on 5 V. The resistors were correct
arithmetic about a circuit that clips, and the top half of the commanded range
would have read flat on a bench with nothing in the schematic looking wrong.

The tasklist entry asked for a PF057 comparing the configured reach against the
declared supply. That turned out to be the wrong move, so the number is not
allocated and not burnt: presents.max and supply_mv are two halves of one claim,
so they are held against each other when the descriptor loads, as a BlockError.
A configurator already reports the reach it built and a reach outside presents
is already PF048, so an honest descriptor makes the configured case impossible by
transitivity rather than by a third comparison. A rule that cannot be violated
beats a finding that reports the violation.

The bound is supply_mv - output_headroom_mv, the new field being what an output
stage cannot reach of its own supply, because rail-to-rail is to within tens of
millivolts and not to the rail. voltage_dac declares 100 mV -- conservative
rather than read off the MCP6002's V_OH table, and its source says so -- with
presents.max at 4.9 V and the module at 1.0.1: a PATCH, because the circuit did
not change. Nobody re-fabricates a board over a number that was always wrong.
The rule is voltage-only; milliohms and hertz are not bounded by a supply in any
unit this could compare, and a rule that fired there would be arithmetic dressed
up as hardware. configure() also refuses a request above its own swing, where the
message can name the rail and the distance, with a test pinning the configurator's
copy of 5 V to the descriptor's.

Two tests were not testing what they said. test_a_reference_below_the_range_-
amplifies reached the gain-stage branch by asking for 10 V, which the module
cannot do; 4.5 V is above the 3 V reference and reaches the same branch honestly.
And test_pf049_a_channel_configured_above_the_logic_rail asked for 10 V, so after
this change the request is refused, firmware comes back empty, and PF049 reads the
descriptor maximum through its fallback -- the comparison that test's own docstring
says is wrong. It asks for 4.5 V now and asserts no PF048, so it cannot pass by
refusal again.

That fallback is deliberate -- a module that built nothing could still be asked
for anything, so the declared envelope is the conservative thing to judge -- but
it is a different number from the one the message claimed. PF049 now names its
evidence, the way PS024 says (courtyard) or (pads).

Left deliberately undone, and moved into the current_sink entry: the fixture
declaring which rails it has. Every module in the tree runs from a rail the
carrier already provides, so fixture.supplies would answer nothing today, and the
fix for a declaration nothing reads cannot sensibly be a second declaration
nothing reads. The sink is the first module whose supply is not the fixture's own.

Verified: 511 tests, lint clean, mypy clean.
_sole_port took the only serial port and refused to guess between several,
which was right as far as it went: the wrong guess talks to whatever else is
plugged in, and the hash check only catches that if the other thing answers.
But a fixture says what it is. client.survey() opens each candidate and asks
fixture.info; client.find_port() decides from what came back.

Four rules fell out of writing it, and each of them is a case where the obvious
version is worse.

One port is still taken without being surveyed. It is the port the caller is
about to open anyway and connect() tells the truth about whatever is on it, so
opening it twice to learn the same thing buys nothing and pokes a device for no
reason.

A single fixture is the fixture whatever firmware it is running. The hash only
disambiguates between several. Answering "no fixture found" because the hash
disagrees would hide the board somebody actually plugged in, and
ConfigMismatchError already says the useful thing about a stale rig.

A port that cannot be opened is one fewer candidate, not an error -- in use by
something else is the ordinary case on a bench.

And a survey that finds nothing lists what it asked and what each one said.
"Your fixture is not here" is only useful if it says what was.

The cost the tasklist named is real, so it is stated rather than hidden: finding
a fixture means writing one line of JSON-RPC to every serial port on the
machine, the printer and the CNC controller included. The survey says on stderr
that it is happening, SURVEY_TIMEOUT is 1 s rather than the call timeout so a
silent port is cheap, and --no-detect opens nothing and refuses when there is a
choice to make -- which is _sole_port, kept for that.

Eleven tests, driven by a port map rather than one fake port: a DeadPort that
opens and never answers is the thing on the next port, and an opener that raises
is the port in use. Two of them go through the CLI, because the stderr line and
the --no-detect refusal are the parts a person actually meets.

Verified: 522 tests, lint clean, mypy clean.
A proof of concept under tools/, not part of the package and not a change to
pinside's position that routing is not generated. Nothing in src/ imports it.

The question it answers is narrower than "should pinside autoroute": can a
direct fixture be routed end to end, by an off-the-shelf router, from what
pinside project already writes. It can. 39 connections, nothing unrouted, 1.3
seconds, 10 vias on two layers; KiCad's own DRC counts 24 unconnected items
before and none after.

The pipeline is pinside project -> place the connector -> pcbnew exports
Specctra DSN -> fastroute -> pcbnew imports the session -> kicad-cli DRC. KiCad
owns both file formats on purpose: a hand-written DSN exporter would have made
this a proof about a dialect somebody guessed at. That also forces the two-script
split, because pcbnew lives in the Python KiCad ships, which is 3.9 in KiCad 10,
and pinside needs 3.10.

Placing the connector is the step nothing does today and the reason the
generated board cannot be routed as it stands: every net on it has exactly one
pad, and a net with one pad has nothing to connect. The position is computed
rather than chosen -- the lowest band clear of every existing pad by 2 mm,
centred in x, the extent taken from the connector's own catalogue dimensions
including the jackscrews that stand outside its shell -- and it goes on the back,
because the DUT sits on the probe side. That is the easy half of the tasklist's
"place the controller and the module parts somewhere sensible"; a connector's
ways are interchangeable and a module's nets are not.

Two findings, both about pinside rather than about the router.

The first run routed everything and then failed DRC on 118 counts: traces at
0.18 and 0.225 mm against a board whose own min_track_width is 0.25. The router
necks down to squeeze between the D-sub's pad rows, where 2.08 mm pitch on 1.6 mm
pads leaves 0.48 mm and a 0.3 mm trace with 0.2 mm clearance needs 0.7. The fix
is one flag fed from the project file pinside itself wrote, so the two cannot
disagree -- but "routed" and "routed to the rules the board states" are different
claims, and anybody pointing a router at a generated fixture should know it.

The second is still there, reported rather than papered over: routing starves
three of the connector's ground thermals, pads 74, 76 and 77, where the pour can
form one spoke and needs two. That is the open tasklist entry about thermal
relief following the probe, arriving as a number on a real board. A proof of
concept that quietly edited the pour would be hiding the finding it exists to
produce. So the tool DRCs the board before and after and judges the difference,
because a generated fixture already carries violations of its own -- two
silkscreen-over-the-edge warnings here -- and blaming the router for those would
be wrong in both directions.

fastroute is GPL-3.0 and pinside is MIT. It is run as a separate program, found
by path; none of its code is vendored or linked. If routing ever became a
feature, that is the question to answer first.

The example DUT is synthetic and bigger than the shipped demo board for one
reason: a DD-78 is 67 mm wide and a 50 x 40 mm board has nowhere to put one.

Verified: the run above, 522 tests, lint and mypy clean.
…to say

Three requests, one piece of work: smaller D-subs, no library dependence for
the PCB, and a clean report from KiCad's own checks.

The catalogue runs dsub_9, dsub_15, dsub_15hd, dsub_25, dsub_37, dsub_62,
dsub_78 and header_78. Size is the column to read first: a fixture is the DUT's
own outline, so the connector has to stand on the board being tested, and before
this the smallest entry was 69.4 mm across -- nothing in the catalogue fitted a
small board at all. dsub_15hd is the one worth knowing about: the same 30.85 mm
DE shell as the 9-way, six more ways.

Every connector is now drawn by pinside into the project's own pinside.pretty.
dsub_37, dsub_62 and header_78 used to name parts out of KiCad's libraries; a
generated board now references no footprint library, because a board that
depends on one depends on that library's version, and a part renamed between
releases -- or missing on the machine the fab files are finally plotted from --
is a board that stops opening. Connector.footprint became kicad_footprint and
names the part KiCad ships purely so the suite can check ours against it, pad
for pad, from pin 1 so the origins need not agree. That check paid for itself
at once: the high-density shells stagger their middle row half a pitch and are
not symmetric about the jackscrews the way a standard-density shell is, so
deriving them from the same formula put every middle-row pad 1.2 mm out, on a
part whose contacts are 2.41 mm apart. All seven agree to 0.000 mm now.

Then the silkscreen, which is where the real defects were.

The board's legend was not on the board. Its name, probe and module versions
were written at y = -6, above the outline, so no fab would print them and the
one place a fixture on a bench carries its own identity was blank. Nothing
noticed because KiCad's canvas is bigger than the board.

Fourteen probes shared one uuid. Every placed footprint took its graphics, text
and pad identity from the shape rather than the instance, so 80 of the board's
475 uuids were duplicates -- which is why DRC reported an overlap against TP14
when the text that actually overlapped belonged to another probe, and why an
exclusion on one would have silenced all fourteen.

A designator printed across its neighbour's pad. The probe field is the DUT's
geometry and cannot move, so only the text can: each designator now takes the
first of four positions clear of every pad, every other label and the board
edge, and is hidden rather than printed when nothing is clear. The legend goes
hard against the top edge, compactly, because the free area below the probe
field is where a connector has to stand -- and a line too wide for the board is
dropped rather than run off it.

Both plates, probe and guide, now report nothing at all under
kicad-cli pcb drc --severity-all --refill-zones, for the managed demo fixture
and for both direct examples. ERC was already clean and still is. The suite
asserts the zero rather than a list of classes it tolerates.

The proof of concept gained the small example that motivated all of this: ten
probes on a 60 x 50 mm board out to a DE-9, routed with 16 connections, no vias
at all and no new DRC findings. Its connector placement now also keeps off the
board's own silkscreen, sliding sideways when the legend is in the way.

Verified: 534 tests, lint and mypy clean, DRC and ERC silent on six generated
boards, both fastroute examples routed end to end.
…reads

A generated fixture had no 3D at all. KiCad's viewer showed a bare green
rectangle, which means the one question a bed-of-nails board exists to answer --
do the pins stand above the plate, and does anything collide with the DUT --
could not be asked of the thing pinside writes.

Every probe and every connector now carries a .wrl, written into the project's
own pinside.3dshapes beside the footprint library, from dimensions the catalogue
already holds. A pin is a cylinder standing travel + 1 mm, which is the figure
guide.py already calls the travel to leave above the guide plate: the model and
the hardware note cannot disagree because there is one number. A connector is
its own footprint outline extruded to Geometry.height_mm.

That height is new, and it is the one dimension a footprint cannot carry, so it
is corroborated rather than typed: it is the z extent of KiCad's own STEP model
for the same part, 10.80 mm for every D-sub in the family and 8.65 for the 2x39
header, and a test reads those models and checks it. The same files confirmed
the shell widths already recorded here, which is a pleasant way to find out the
pad cross-check was not a fluke.

The models are representative solids and each says so in its own header. Where
the catalogue has no number -- this probe's plunger diameter, which --guide
deliberately refuses to guess because a guide hole is drilled -- the file states
which part of it is a picture. A hole gets no model at all: a mounting hole is an
absence, and a block of plastic where the screw goes is worse than nothing.

Two things cost a render each to learn. KiCad's VRML reader takes IndexedFaceSet
and only that: a file built from Box and Cylinder nodes parses without a word of
complaint and draws nothing, which is how the first version shipped a 3D model
that did not exist. And a (model) node must not carry a uuid, though every other
node pinside writes does -- with one, FootprintLoad returns None and the first
symptom is a connector that will not place. Both have tests.

The VRML unit is 0.1 inch. That was measured rather than remembered: a box of
known size through kicad-cli pcb export vrml comes back scaled by 2.54.

README now opens with the result -- a routed direct fixture in KiCad's 3D view,
ten pins standing over the plate -- because that picture says more about what
this tool is for than the paragraph above it does.

Verified: 542 tests, lint and mypy clean, both fastroute examples rerouted and
rendered, DRC still silent on every generated board.
Pointed the proof of concept at a real open-source board -- td-free, a 60 x 30 mm
filament densitometer with five test points on its underside -- by writing a
direct-mode config against that project's own .kicad_pcb and changing nothing
else. It reported that the board had no room for a 9-way D-sub.

It had room for exactly one position. The probe row sits at y = 25 and the
board's legend ends at y = 7.2, which leaves a window 0.15 mm wide once both
keep their 2 mm. The scan stepped 0.5 mm and went straight over it.

So the step is 0.1 mm now, and the comparisons carry a micron of tolerance:
21.75 - 63 * 0.1 is 15.449999999999996, and a position that meets the clearance
exactly was being refused by four parts in a quadrillion. With both, that board
routes: 7 connections, nothing unrouted, no vias, and DRC silent before and
after.

A real board is the only thing that would have found this. The synthetic
examples have room to spare in every direction, which is exactly what makes
them bad at catching a scan that is too coarse.
Pulled every KiCad board a GitHub search could find that carries TestPoint
footprints -- fifty readable ones -- and ran check over all of them. The reader
coped with KiCad 6 through 10 and found 37 mounting holes. Two entries come out
of what it got wrong.

A KiCad 5 board reads as empty and says so in no way at all. OtterPill has 44
parts; check reports zero test points, zero mounting holes, and then PS002,
PS030 and PS050 with complete confidence, because KiCad 5 writes (module ...)
and board.py reads (footprint ...). The outline still parses, which is what
makes the report convincing rather than obviously broken, and the exit status is
0. Supporting the old format is not interesting. Refusing it clearly is, and the
detection is one substring.

The second is a decision rather than a feature, and the sweep is what makes it
one. A board can say "there is a hole here" with a bare unplated pad or a circle
on Edge.Cuts, and pinside sees neither -- but exactly two boards in fifty carry
such geometry and both would have been false positives: td-free's unplated pads
are a USB-C receptacle's peg holes, and altest2's Edge.Cuts circle is the board
outline. A phantom standoff under a connector is worse than a missing one,
because PS050 is a warning somebody reads while an invented hole quietly changes
PS052, the support footprint and the drill plan. So the entry says decide: a
rule that can tell a peg from a standoff, or a declaration like
component_heights.

The section also records where the hard boards are. kivu12 has 67 named test
points and nine probes with less room beside a component than a probe needs,
which is a harder workout than anything in tests/boards.py.
Ran the Amiga 2000 Rev 6 recreation through check, because somebody asked what
pinside makes of it. Three things came back.

It is a KiCad 5 file, so the report is the confident description of an empty
board the previous entry describes -- 553 parts, none of them read. That entry
now names it, because "a rare old file" was the weakest part of the argument for
fixing it and a 1987 motherboard recreation updated four months ago is not rare.

Converted, it reads in 1.3 seconds at 11.6 MB and 553 footprints, which is worth
knowing about the reader.

And it carries ten mounting holes that pinside does not see. is_mounting_hole
asks whether the footprint's library id contains "MountingHole"; this board's
are "Amiga 2000R6:Mounting_Hole", in its own library with its own spelling, and
their references are E1-E6, M6 and V1-V3 so the reference pattern misses them
too. That is now its own entry, separate from the bare-pad question, because
unlike that one it has an obvious fix with no false positive in it: strip the
non-letters, lowercase, look for "mountinghole". A footprint whose name says
mounting hole is one. The same normalisation belongs on is_test_point, where
nothing in the pool has needed it yet and Test_Point is the next spelling to
arrive.
…d keep twenty boards to prove it

Two fixes the Amiga 2000 board found, and the corpus that found them turned
into something that runs.

is_mounting_hole asked whether the footprint's library id contained
"MountingHole". That is a substring match on a name somebody else chose: the
Amiga recreation carries ten holes as "Amiga 2000R6:Mounting_Hole", and pinside
reported that a 553-part motherboard had none, then advised that a board with no
holes cannot be located. The id is normalised now -- non-letters out,
lowercased -- and the same went on is_test_point, where nothing in the corpus
has needed it yet and Test_Point is the next spelling to arrive.

A KiCad 5 board is refused rather than read as empty. KiCad 5 writes (module
...) where 6 and newer write (footprint ...), so 553 parts came back as zero
test points, zero mounting holes and therefore no ground -- and the outline
parses either way, which made the report look like a board with nothing on it
rather than a file this cannot read.

The tasklist entry asked for a finding code and that was the wrong shape. A
finding is something wrong with a board; this is a file the reader cannot see
into, so LegacyFormatError comes out of read_board and the CLI reports it the
way it reports any unreadable file. It keys on the node name rather than a
version number, because the node name is what actually makes every part
invisible, and a board with neither modules nor footprints is simply empty and
stays readable.

scripts/wild.py is the corpus. Twenty open-source KiCad projects, each pinned to
a commit in scripts/wild-boards.json, fetched at run time and never vendored:
the licences are recorded per entry, the cache is ignored, and the report quotes
counts and finding codes rather than anybody's geometry. It reads each board,
runs every check, writes wild-report.md, and exits non-zero when one stops
behaving the way the manifest says it did -- status, test-point count and
mounting-hole count, all three measured at that commit rather than guessed.

The pin is the point. These repositories move, and a corpus tracking their main
branches would fail for reasons that have nothing to do with this package. With
the commit fixed, a disagreement is a change here.

Fifteen of the twenty read, five are refused as KiCad 5, and between them they
reach sixteen finding codes -- PS001, PS003 and PS051 among them, which no board
in tests/boards.py reaches. Both fixes above were invisible to 547 passing tests,
because every board in that suite was written by somebody who already knew what
the reader expects.

Verified: 547 tests, lint and mypy clean, and wild.py green on all twenty both
online and from the cache.
… can read

Thirty more open-source KiCad boards in scripts/wild-boards.json, each pinned to
a commit and measured at it: status, test-point count, mounting-hole count and
the file's own format version, which the script now checks as well. The version
is fixed by the commit, so checking it costs nothing and catches a fetch that
brought back something else.

All thirty are KiCad 6 or newer -- 20211014 at the oldest, 20260206 at the
newest. Five refusals are enough to prove the refusal; past that, a board earns
its place by giving the reader work to do, and a sixth KiCad 5 file would only
make the run slower.

Choosing them was mostly rejecting them. One board per repository, exact
duplicates dropped by content hash, and near-duplicates dropped by signature:
the same demo board turns up under three different names in search results, and
three copies of 48 x 21 mm with four test points measure nothing three times.
What is left spans 12 x 14 mm to 191 x 178 mm, one to 48 test points, zero to
six mounting holes, and one board with no Edge.Cuts outline at all.

Fifty boards now: 45 read, 5 refused, 324 test points, 78 mounting holes, and 26
distinct finding codes between them -- up from 16 with twenty boards. PS001,
PS003 and PS051 are in that set and no board in tests/boards.py reaches any of
them.

Verified: 547 tests, lint and mypy clean, wild.py green on all fifty online and
from the cache.
… fixture a ground post

Two of the three findings that fire most often on real boards were dead ends:
the board is drawn, the pads cannot move, and the advice was a shrug. They say
something useful now.

PS021 names the probe. "Use a finer probe" is the only advice worth having about
a board that already exists, so the finding gives it: the coarsest probe in the
catalogue that clears the closest pair, which is the smallest change that works.
Nothing fine enough, and it says that instead, because then the pads really do
have to move. SNEdge's two 2.50 mm pairs now answer soldered_1mm. The catalogue
is a layer above checks.py and must stay there, so the options arrive in Limits
rather than by import.

PS022 measured a wall nobody had declared. A generated fixture has no wall -- the
plate is the DUT's own outline -- and what actually runs out of room beside a
probe at the edge is the receptacle's own pad against the fab's copper-to-edge
minimum. The limit is that sum now, 1.645 mm for the default probe and
recomputed from whichever probe the config chose, and the finding says what it is
made of. Twelve of the fifty corpus boards raised it; ten still do, and those ten
are true. fixture.edge_wall_mm is for a fixture that does live in an enclosure.

And every fixture now carries J2, a 1x02 ground post in whichever corner has
room. Pin 1 is the fixture's own ground. Pin 2 is the DUT's ground through its
probe, which on a managed fixture is a different net -- and a meter across the
pair reads the resistance of the ground contact itself. That is the fault a
bed-of-nails rig has most often and reports least: a tired pin on the return path
moves every measurement referenced to it, and nothing in the test says so. On a
harness both pins are GND and it is two posts to clip a lead to.

The post is a part like any other here: its footprint and its 3D model are
generated into the project, one function decides pin 2's net for the board and
the schematic so the two cannot disagree, and a board with no room gets no post
rather than one pressed into the probe field. Its pads are checked against
KiCad's PinHeader_1x02 -- which is how the first version was caught drawing the
pins side by side when KiCad runs them down the part.

One builder now serves both the library footprint and the placed one, because a
footprint that differs between the two is the kind of thing nobody notices until
a pad moves.

Verified: 553 tests, lint and mypy clean, ERC and DRC silent on four generated
boards, and the corpus still agrees with its manifest.
PS052 is the last of the three findings that could only report. A probe field
reaching past the mounting holes puts the probe force on a cantilever, and the
check can do nothing about it: the holes are the DUT's. The plate is not.

pinside project now places an M3 standoff at each corner of the probe field that
no mounting hole reaches -- S1, S2 and so on, on the probe plate and the guide
plate both. Twenty-five millimetres is how far a standoff counts as holding, and
that is a working figure rather than a measured one: it is the span PS029's bow
arithmetic finds negligible for a 1.6 mm plate under a handful of probes.

The positions come from geometry alone, and that is the part worth keeping. The
two plates carry different parts and different text, so anything that let a
designator or a legend nudge a support would drill the two boards differently --
a pair that cannot be assembled and looks right in every plot. plan_supports
sees probes, holes and the outline, and nothing else.

Two things fell out of it. The legend now gives up a line at a time rather than
vanishing: with the standoffs and the post placed first, the full block stopped
fitting on the 120 x 80 example and the board came out with no name on it at
all. Paint loses to parts, but the name is the line worth keeping. And the
support's own footprint joins the ones written into pinside.pretty -- the board
referenced MountingHole_3.2mm with no file behind it, which DRC called out
twice per plate.

fixture.supports: false turns them off for a base plate already drilled to
somebody else's pattern.

Verified: 557 tests, lint and mypy clean, DRC and ERC silent on six boards
across three fixtures, and the corpus still agrees with its manifest.
Two mentions of a specific product, in the examples README and in a comment
beside the bare-RP2350B config. Neither carried anything the sentence needed:
what matters about "board": "bare" is that a fixture can need more pins than any
carrier brings out, not whose board that was.

Both now say that instead. The examples README keeps the explanation it was
about to lose with the name -- a working tree already had the sentence deleted
outright, which took the reason for the bare target with it.
CI ran the direct-fixture tests for the first time on this branch and ten of
them failed on every Python version. Not a merge problem and not a version
problem: tests/test_direct.py was written on a machine with KiCad installed and
never said it needed one. generate_project raises PK003 without KiCad's symbol
libraries and load_symbol raises LibraryError, so the tests that place a
connector, write a project or cross-check a footprint against KiCad's own
errored rather than skipped -- and test_pk006 failed outright, because without
KiCad the refusal it asserts arrives as PK003.

The guard is the one test_blocks.py and test_kicad.py already use, so this file
now behaves like the rest of the suite: probe symbol_dir() at import, skip when
it is missing, and let PINSIDE_REQUIRE_KICAD=1 turn the skips back into
assertions. That flag is what the kicad job sets, so the same ten tests still
run inside KiCad's container -- a missing library must not read as a pass.

557 tests, 56 skipped, green without KiCad; 557 green with it.
CI reached the test job for the first time with the last commit and the other
three jobs with it. Each found something real that no amount of running the
suite on this machine could have.

The firmware job is the one that matters. fixture_hal_rp2350.c includes
hardware/pwm.h and CMakeLists.txt never linked hardware_pwm, so the
cross-compile stopped at the include -- and had done since fx_hal_pwm_set was
added. Nothing local could see it: the host build uses the mock HAL, which
includes no SDK header at all, so every machine with a C compiler built the
firmware happily. The library is linked now, and a test compares the headers the
HAL includes against the libraries the CMakeLists links, which is the cheap half
of that job and runs everywhere.

The lint job failed on mypy: pyserial is an optional runtime dependency and the
lint environment installs the pinned tools and nothing else, so mypy found
neither `serial` nor stubs for it. Demanding types-pyserial there would make the
type check depend on a package the package itself does not, so the override says
what is true instead -- the client's use of it is four calls wide and is covered
by tests against a fake port.

The KiCad job failed on two things. KiCad's container ships the libraries and
not the 3D models, so the height cross-check found no files and asserted that it
had checked nothing -- which is exactly what that assertion is for, and it now
skips on its own terms, naming the directory it wanted. And
test_client.AgainstTheRealFirmware builds a replay binary in setUpClass with no
guard, so in a container with no compiler it errored where every other build
test skips. It takes the same guard now, reading $CC the way the build does.

558 tests green here, and green again with KiCad and the compiler taken away:
68 skipped, which is what the runners see.
With hardware_pwm linked, the cross-compile got as far as clock_get_hz and
stopped there: the HAL calls it and clk_sys without including
hardware/clocks.h. The library was already linked -- that was fixed when this
first bit, back when the symptom was a link error -- so the include path existed
and the declaration did not, and -Werror=implicit-function-declaration turned
what used to be a link failure into a compile one.

The guard added with hardware_pwm only checked one direction: every header
included has a library behind it. This is the other direction, and it is the one
that has now bitten twice -- a call whose header is missing builds fine on a host
that never sees the SDK. The table is eight entries and is the SDK surface this
HAL actually uses; taking the clocks include back out fails it.

558 tests, lint and mypy clean, and the generated firmware's own 71 host checks
still pass.
@Boernsman
Boernsman force-pushed the contact-force-checks branch from bc6d75a to 54cae8a Compare October 14, 2026 19:45
@Boernsman
Boernsman merged commit 581188e into main Oct 14, 2026
9 checks passed
@Boernsman
Boernsman deleted the contact-force-checks branch October 14, 2026 19:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant