From 41c410fc332900de74a46fb38de739f89cb9cb0c Mon Sep 17 00:00:00 2001 From: Bernhard Trinnes <5207214+Boernsman@users.noreply.github.com> Date: Sun, 30 Aug 2026 20:08:26 +0200 Subject: [PATCH] Check what the probes push with, and what the DUT does under it One spring pin is 0.75 N, which is nothing, so nobody thinks about the force when they add one more test point. Two hundred of them is 150 N and a lever clamp. That load closes the fixture, is carried by the DUT's standoffs, and between them the board is an unsupported plate with the whole of it spread across its middle: it bows, the probes in the centre over-travel while the outer ones stop reaching, and the fixture reads intermittent on whichever channel is furthest from a hole. Nothing in the drill plan shows either half of that. Every probe on such a board is placed, spaced and netted correctly; the only thing wrong with it is how many there are, which is exactly the number no check looked at. PS028 is the total: probes times the per-probe force, reported in newtons and kgf with the share each mounting hole carries, past a default of 100 N. PS029 is the consequence, estimated as a uniformly loaded rectangular plate simply supported across the span the mounting holes give it, with Roark's deflection coefficient interpolated over the aspect ratio. Both approximations in that model -- even load, and simple supports where the board is really bolted -- under-report the bow, which is the direction a warning should be wrong in. Stiffness goes as the cube of the thickness, so the number is worthless without it: read_board now takes (general (thickness ...)) off the file, and the finding says when it had to assume 1.6 mm FR-4 instead. The shipped example board declares its thickness the way a real KiCad file does. The probe a fixture config chooses sets the force, the same way it already sets the pitch and the body diameter, so a lighter pin relaxes both findings without a second edit. --probe-force, --max-force and --max-deflection are there for a probe the catalogue does not have. Past a bow of about the board's own thickness the linear plate model stops being true -- the board starts carrying load by stretching as well as bending -- so the finding says so rather than letting a 6.7 mm figure be quoted as a measurement. Verified against the example board, which stays clean at the defaults and reports both findings when the limits are tightened, and against synthetic 20x10 probe fields at 0.8, 1.6 and 2.4 mm: 6.71, 0.84 and 0.25 mm of bow, the cube law the model claims. The suite is 229 tests, all passing. --- CHANGELOG.md | 13 ++++ README.md | 26 +++++-- examples/demo-board.kicad_pcb | 1 + src/pinside/board.py | 19 +++++ src/pinside/checks.py | 134 ++++++++++++++++++++++++++++++++++ src/pinside/cli.py | 21 ++++++ src/pinside/kicad/project.py | 6 +- src/pinside/report.py | 1 + tests/boards.py | 37 ++++++++++ tests/test_pinside.py | 57 +++++++++++++++ 10 files changed, 307 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 326efd2..10fd6b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,19 @@ existing agents wrong. ## [Unreleased] +### Added + +- **Contact-force checks.** `PS028` reports the closing force a plate of probes adds up to -- + one 0985 pin is 0.75 N, two hundred are 150 N and a lever clamp -- and `PS029` estimates how + far that force bows the DUT between its mounting holes, treating it as a uniformly loaded + plate. Neither is visible in the drill plan: they are properties of how many probes there + are, which is the number nobody revisits after adding one more test point. The probe chosen + in a fixture config sets the per-probe force, so a lighter pin relaxes both. +- The board's stackup thickness is read from `(general (thickness ...))` and reported as + `thickness_mm` in the JSON output. It is what decides how stiff the DUT is; boards that + declare none are assumed to be 1.6 mm FR-4, and the finding says so. +- `--probe-force`, `--max-force` and `--max-deflection` on `pinside check`. + ## [0.2.0] The fixture board, the firmware's host side, and the first release published to PyPI. diff --git a/README.md b/README.md index a79453a..6855bc2 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,7 @@ receptacle. Change them when your probes differ: ```bash pinside board.kicad_pcb --probe-pitch 1.9 --probe-body 1.27 --edge-clearance 1.5 --min-pad 0.7 +pinside board.kicad_pcb --probe-force 0.55 --max-force 100 --max-deflection 0.5 ``` `--ignore PS041,PS042` silences findings you have already decided about. @@ -140,6 +141,8 @@ pinside generate fixture.json --out firmware/ --json | jq '.errors' | PS025 | warning | A test pad is too small for a spring tip plus placement tolerance | | PS026 | warning | Test points on both sides; one plate cannot reach them all | | PS027 | warning | The tip clears a component but the receptacle body does not | +| PS028 | warning | The probes add up to more force than a hand clamp will close | +| PS029 | warning | That force bows the DUT between its supports, so contact goes uneven | | PS030 | error | No ground test point: there is no return path to measure against | | PS031 | warning | Only one ground probe | | PS032 | info | Plated mounting holes carry no net; grounding them is a free return path | @@ -157,6 +160,15 @@ pinside generate fixture.json --out firmware/ --json | jq '.errors' PS010 and PS012 are the two that matter most in practice: both mean *the layout is not finished*, and any fixture cut from those coordinates is scrap. +`PS028` and `PS029` are about force rather than geometry, and they are the two that only +appear on a board somebody kept adding test points to. One spring pin pushes 0.75 N, which is +nothing; two hundred of them push 150 N, which is a press. That load closes the fixture, is +carried by the DUT's standoffs, and between them the board is an unsupported plate with the +whole of it spread across its middle. It bows, the probes in the centre over-travel while the +outer ones stop reaching, and the fixture reads intermittent on whichever channel is furthest +from a hole. The bow is estimated as a uniformly loaded, simply supported plate, using the +stackup thickness from the board file when it declares one and 1.6 mm FR-4 when it does not. + `PS043` and `PS044` are about net *numbering* rather than geometry. Through KiCad 9 a net is identified by its ordinal and the name beside it is only a label, so two names on one number are one net as far as KiCad is concerned: it keeps the first and drops the rest to no-net on the next @@ -246,14 +258,14 @@ involves. `millmax_0985`: a Mill-Max 0985 receptacle with an 0900 spring pin, on a 2.54 mm pitch. The receptacle is what makes a fixture maintainable: a worn pin pulls out and a new one goes in. -| Probe | Hole | Pad | Minimum pitch | -|---|---|---|---| -| `millmax_0985` (default) | 1.37 mm | 2.29 mm | 2.54 mm | -| `millmax_0906` | 1.02 mm | 1.70 mm | 1.91 mm | -| `soldered_1mm` | 1.02 mm | 1.60 mm | 2.00 mm | +| Probe | Hole | Pad | Minimum pitch | Force | +|---|---|---|---|---| +| `millmax_0985` (default) | 1.37 mm | 2.29 mm | 2.54 mm | 0.75 N | +| `millmax_0906` | 1.02 mm | 1.70 mm | 1.91 mm | 0.55 N | +| `soldered_1mm` | 1.02 mm | 1.60 mm | 2.00 mm | 0.70 N | -The probe sets the spacing limit, so choosing a finer one relaxes the `PS021` check without a -second edit. +The probe sets both the spacing limit and the force, so choosing a finer one relaxes the `PS021` +check and lowers the load `PS028` and `PS029` measure, without a second edit. Dimensions are what pinside builds to, so check them against your supplier's drawing before ordering. Each entry records where its numbers came from and whether anyone has done that check; diff --git a/examples/demo-board.kicad_pcb b/examples/demo-board.kicad_pcb index 2f611fb..8bf4c8a 100644 --- a/examples/demo-board.kicad_pcb +++ b/examples/demo-board.kicad_pcb @@ -1,4 +1,5 @@ (kicad_pcb (version 20241229) (generator "pinside-tests") + (general (thickness 1.6)) (net 0 "") (net 1 "/DUT_TXD") (net 2 "/DUT_RXD") diff --git a/src/pinside/board.py b/src/pinside/board.py index 6eae2a7..5e216db 100644 --- a/src/pinside/board.py +++ b/src/pinside/board.py @@ -160,6 +160,10 @@ class Board: # a KiCad 10 board, which has no ordinals to disagree about. net_ordinals: dict[int, set[str]] = field(default_factory=dict) + # The stackup thickness the board file declares, mm. None when it declares none, which is + # what a hand-written or minimal file does: how stiff the DUT is then has to be assumed. + thickness: float | None = None + @property def nets(self) -> set[str]: """Every named net the board file mentions, from any pad on any footprint. @@ -339,6 +343,20 @@ def read_net_ordinals(tree) -> dict[int, set[str]]: return ordinals +def read_thickness(tree) -> float | None: + """The board's own stackup thickness, from `(general (thickness 1.6))`. + + It is the one number in the file that says how stiff the DUT is, and stiffness is what + decides whether the probes bow it. A board that does not declare one is not an error: the + force check falls back to an assumption and says that it did. + """ + for general in find_all(tree, "general"): + values = floats(child(general, "thickness")) + if values and values[0] > 0: + return values[0] + return None + + def read_board(path: str) -> Board: tree = load(path) outline = read_outline(tree) @@ -394,6 +412,7 @@ def order(ref: str) -> tuple[str, int]: mounting_holes=holes, obstacles=obstacles, net_ordinals=read_net_ordinals(tree), + thickness=read_thickness(tree), ) diff --git a/src/pinside/checks.py b/src/pinside/checks.py index a436d92..5de25d4 100644 --- a/src/pinside/checks.py +++ b/src/pinside/checks.py @@ -31,6 +31,14 @@ class Limits: grid_tolerance: float = 0.01 # how exactly a coordinate must sit on a lattice, mm grid_fraction: float = 0.7 # share of probes on it before we call it an import grid + # What the probes push with, and what the DUT will take. One pin is nothing; a plate of + # them is a press, and the board in the middle of it is what gives way first. + probe_force: float = 0.75 # N per probe at full travel, for a 0985 receptacle + 0900 pin + max_total_force: float = 100.0 # N before the plate needs a lever or a pneumatic clamp + max_deflection: float = 0.5 # mm the DUT may bow between its supports + board_thickness: float = 1.6 # mm, when the board file declares no stackup thickness + board_modulus: float = 20000.0 # MPa, FR-4 in-plane flexural; ~20 GPa + @dataclass class Finding: @@ -379,6 +387,131 @@ def check_probe_body(board: Board, limits: Limits) -> list[Finding]: return [] +# Roark, rectangular plate with all four edges simply supported under a uniform load: the +# maximum deflection is alpha * q * b**4 / (E * t**3), with b the shorter span. Alpha climbs +# with the aspect ratio because a long plate stops being able to carry its load in two +# directions and starts behaving like a beam across the short one. +_PLATE_ALPHA = [ + (1.0, 0.0444), + (1.2, 0.0616), + (1.4, 0.0770), + (1.6, 0.0906), + (1.8, 0.1017), + (2.0, 0.1106), + (3.0, 0.1336), + (4.0, 0.1400), +] +_PLATE_ALPHA_LONG = 0.1421 # the strip limit, b/a -> infinity + + +def _plate_alpha(ratio: float) -> float: + """Roark's deflection coefficient for a long-side/short-side ratio, interpolated.""" + if ratio >= _PLATE_ALPHA[-1][0]: + return _PLATE_ALPHA_LONG + for (r0, a0), (r1, a1) in pairwise(_PLATE_ALPHA): + if ratio <= r1: + return a0 + (a1 - a0) * (ratio - r0) / (r1 - r0) + return _PLATE_ALPHA_LONG + + +def _bow(force: float, span: BBox, thickness: float, modulus: float) -> float: + """How far the middle of the DUT sinks under the probes, mm. + + The DUT is treated as a uniformly loaded plate, simply supported around the span it is held + on. Both halves of that are approximations -- the probe force is not perfectly even and + bolted holes are stiffer than a simple support -- and both of them under-report the bow, + which is the right direction for a warning to be wrong in. + """ + short, long = sorted((span.width, span.height)) + if short <= 0 or thickness <= 0 or modulus <= 0: + return 0.0 + pressure = force / (short * long) # N/mm2, spread over the supported span + return _plate_alpha(long / short) * pressure * short**4 / (modulus * thickness**3) + + +def check_contact_force(board: Board, limits: Limits) -> list[Finding]: + """What the whole plate of probes pushes with, and what the DUT does under it. + + One spring pin is nothing: 0.75 N, less than the weight of a AA cell. Two hundred of them + is a press. The force is what closes the fixture, so it is carried by whatever holds the + lid down and then by the DUT's own mounting holes -- and between those holes the board is + an unsupported plate with the entire load spread across it. It bows, the probes in the + middle over-travel while the ones at the edge stop reaching, and the fixture reads + intermittent on whichever channel is furthest from a standoff. + + Nothing about the drill plan shows this. It is a property of how many probes there are, + which is exactly the number nobody revisits after adding one more test point. + """ + probes = len(board.test_points) + if not probes or limits.probe_force <= 0: + return [] + + total = probes * limits.probe_force + kgf = total / 9.81 + holes = board.mounting_holes + out = [] + + if total > limits.max_total_force: + per_hole = f"{total / len(holes):.0f} N per mounting hole" if holes else "no mounting holes" + out.append( + Finding( + "PS028", + WARNING, + f"{probes} probes at {limits.probe_force:g} N close with " + f"{total:.0f} N ({kgf:.1f} kgf)", + [f"{probes} probes", per_hole], + f"past the {limits.max_total_force:g} N this allows, which is more than a " + "toggle clamp or a few thumbscrews will hold down: it needs a lever or " + "pneumatic clamp, and every newton of it is carried by the DUT's standoffs. " + "Use a lighter spring pin, or probe fewer points at once", + ) + ) + + # The span the DUT is actually held on. Three holes make a plane and are the real supports; + # with fewer (PS050's finding) the board rests on whatever the fixture frame gives it, which + # is its own edge, so the outline is the honest fallback rather than a reason to say nothing. + span, support = None, "" + if len(holes) >= limits.min_mounting_holes: + xs, ys = [h.x for h in holes], [h.y for h in holes] + span = BBox(min(xs), min(ys), max(xs), max(ys)) + support = f"supported {span.width:.0f}x{span.height:.0f} mm across {len(holes)} holes" + elif board.outline.bbox: + span = board.outline.bbox + support = f"supported {span.width:.0f}x{span.height:.0f} mm at the board edge" + if span is None or min(span.width, span.height) <= 0: + return out # colinear holes or no outline: there is no span to bend across + + thickness = board.thickness or limits.board_thickness + stated = "" if board.thickness else " assumed" + bow = _bow(total, span, thickness, limits.board_modulus) + if bow > limits.max_deflection: + # Small-deflection plate theory stops being true once the plate bows about as far as it + # is thick: the board starts carrying load by stretching as well as bending, which the + # formula does not know about, so it over-reports. Saying so costs one clause and stops + # the number being quoted as though it were measured. + over_range = ( + " The bow is past the board's own thickness, where this model overstates it: " + "read it as far too much, not as a measurement." + if bow > thickness + else "" + ) + out.append( + Finding( + "PS029", + WARNING, + f"{total:.0f} N of probe force bows the DUT about {bow:.2f} mm " + f"between its supports", + [f"{thickness:g} mm board{stated}", support, f"{probes} probes"], + f"more than the {limits.max_deflection} mm this allows, estimated as a " + "uniformly loaded FR-4 plate. At that much bow the probes in the middle " + "over-travel while the outer ones stop reaching, and the joints under any " + "large BGA take the strain. Add supports inside the span, use a lighter " + "spring pin, or back the DUT with a stiffener." + over_range, + ) + ) + return out + + def check_pad_size(board: Board, limits: Limits) -> list[Finding]: small = [ f"{t.ref} {t.pad.min_dimension:g}mm" @@ -659,6 +792,7 @@ def check_mounting(board: Board, limits: Limits) -> list[Finding]: check_hole_clearance, check_obstructions, check_probe_body, + check_contact_force, check_pad_size, check_sides, check_ground, diff --git a/src/pinside/cli.py b/src/pinside/cli.py index 327f324..04a3937 100644 --- a/src/pinside/cli.py +++ b/src/pinside/cli.py @@ -78,6 +78,9 @@ def _limits_from(args) -> Limits: hole_clearance=args.hole_clearance, min_pad_diameter=args.min_pad, min_mounting_holes=args.min_holes, + probe_force=args.probe_force, + max_total_force=args.max_force, + max_deflection=args.max_deflection, ) @@ -484,6 +487,24 @@ def build_parser() -> argparse.ArgumentParser: default=d.min_mounting_holes, help="mounting holes needed to locate the board", ) + limits.add_argument( + "--probe-force", + type=float, + default=d.probe_force, + help="spring force of one probe at full travel, N", + ) + limits.add_argument( + "--max-force", + type=float, + default=d.max_total_force, + help="total closing force the fixture may need before it wants a press, N", + ) + limits.add_argument( + "--max-deflection", + type=float, + default=d.max_deflection, + help="how far the DUT may bow between its supports under the probes, mm", + ) check.add_argument( "--ignore", metavar="CODES", diff --git a/src/pinside/kicad/project.py b/src/pinside/kicad/project.py index e8246c3..093b82c 100644 --- a/src/pinside/kicad/project.py +++ b/src/pinside/kicad/project.py @@ -278,7 +278,11 @@ def generate_project( probe = config.probe_part findings = findings + check_board( board, - Limits(probe_pitch=probe.min_pitch_mm, probe_body=probe.body_dia_mm), + Limits( + probe_pitch=probe.min_pitch_mm, + probe_body=probe.body_dia_mm, + probe_force=probe.force_n, + ), ) if any(f.severity == ERROR for f in findings): diff --git a/src/pinside/report.py b/src/pinside/report.py index 6e8c149..4d2a1e7 100644 --- a/src/pinside/report.py +++ b/src/pinside/report.py @@ -110,6 +110,7 @@ def emit_json(board: Board, findings: list[Finding], out=sys.stdout) -> None: payload = { "source": board.source, "frame": board.frame, + "thickness_mm": board.thickness, "outline": { "closed": board.outline.closed, "bbox": box.as_dict() if box else None, diff --git a/tests/boards.py b/tests/boards.py index 00b9640..c3c23a3 100644 --- a/tests/boards.py +++ b/tests/boards.py @@ -216,6 +216,43 @@ def panelised() -> str: return _wrap(body) +def dense_field( + cols: int = 20, rows: int = 10, width: float = 160.0, height: float = 100.0, thickness=None +) -> str: + """A big board under a full plate of probes: the case the force checks exist for. + + Every probe here is placed, spaced and netted correctly. The only thing wrong with the board + is how many of them there are, which is why no geometric check says anything about it. + """ + body = rect_outline(0, 0, width, height) + n = 0 + for row in range(rows): + for col in range(cols): + n += 1 + ground = n <= 2 + body += _testpoint( + f"TP{n}", + 10 + col * 2.54, + 10 + row * 2.54, + "GND" if ground else f"/SIG{n}", + value="GND" if ground else "TestPoint", + ) + for i, (x, y) in enumerate( + [(5, 5), (width - 5, 5), (5, height - 5), (width - 5, height - 5)], start=1 + ): + body += _hole(f"H{i}", x, y, net="GND") + return with_thickness(_wrap(body), thickness) if thickness else _wrap(body) + + +def with_thickness(text: str, thickness: float) -> str: + """Give a board the `(general (thickness ...))` a real KiCad file carries.""" + return text.replace( + '(generator "pinside-tests")', + f'(generator "pinside-tests") (general (thickness {thickness}))', + 1, + ) + + def unreachable_rails() -> str: """Probes on the data lines and on nothing that powers or resets the board.""" body = rect_outline() diff --git a/tests/test_pinside.py b/tests/test_pinside.py index c8afa92..5281b3d 100644 --- a/tests/test_pinside.py +++ b/tests/test_pinside.py @@ -385,6 +385,63 @@ def test_a_finer_probe_relaxes_it(self): self.assertNotIn("PS027", codes(boards._wrap(body), Limits(probe_body=1.0))) +class ContactForce(unittest.TestCase): + """What a whole plate of probes pushes with, and what the DUT does under it.""" + + def test_a_handful_of_probes_says_nothing(self): + found = codes(boards.healthy()) + self.assertNotIn("PS028", found) + self.assertNotIn("PS029", found) + + def test_a_full_plate_of_probes_is_reported(self): + found = codes(boards.dense_field(thickness=1.6)) + self.assertIn("PS028", found) # 200 x 0.75 N = 150 N, past a hand clamp + self.assertIn("PS029", found) # and it bows a 1.6 mm board most of a millimetre + + def test_the_finding_names_the_force_and_the_bow(self): + board = transform(read_board(write(boards.dense_field(thickness=1.6)))) + force = next(f for f in run(board) if f.code == "PS028") + self.assertIn("150 N", force.summary) + self.assertIn("38 N per mounting hole", force.refs) + + bow = next(f for f in run(board) if f.code == "PS029") + self.assertIn("1.6 mm board", bow.refs) + self.assertIn("150x90 mm across 4 holes", " ".join(bow.refs)) + + def test_a_lighter_spring_pin_relaxes_both(self): + # The same board probed with something that pushes a tenth of a newton. + found = codes(boards.dense_field(thickness=1.6), Limits(probe_force=0.1)) + self.assertNotIn("PS028", found) + self.assertNotIn("PS029", found) + + def test_a_thicker_board_takes_the_same_load(self): + # Stiffness goes as the cube of the thickness, so 2.4 mm bows a third as far. The force + # is unchanged, which is the point of the two findings being separate. + found = codes(boards.dense_field(thickness=2.4)) + self.assertIn("PS028", found) + self.assertNotIn("PS029", found) + + def test_a_thin_board_bows_further(self): + thin = transform(read_board(write(boards.dense_field(thickness=0.8)))) + thick = transform(read_board(write(boards.dense_field(thickness=1.6)))) + + def bow(board): + return next(f for f in run(board) if f.code == "PS029") + + self.assertIn("model overstates it", bow(thin).detail) # bow past the board's thickness + self.assertNotIn("model overstates it", bow(thick).detail) + + def test_an_undeclared_thickness_is_assumed_and_said_so(self): + board = transform(read_board(write(boards.dense_field()))) + self.assertIsNone(board.thickness) + bow = next(f for f in run(board) if f.code == "PS029") + self.assertIn("1.6 mm board assumed", bow.refs) + + def test_the_declared_thickness_is_read(self): + board = read_board(write(boards.dense_field(thickness=0.8))) + self.assertEqual(board.thickness, 0.8) + + class NetRecordFormats(unittest.TestCase): """KiCad 9 writes (net "NAME"); KiCad 10 dropped the ordinal.