Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
26 changes: 19 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 |
Expand All @@ -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
Expand Down Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions examples/demo-board.kicad_pcb
Original file line number Diff line number Diff line change
@@ -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")
Expand Down
19 changes: 19 additions & 0 deletions src/pinside/board.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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),
)


Expand Down
134 changes: 134 additions & 0 deletions src/pinside/checks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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,
Expand Down
21 changes: 21 additions & 0 deletions src/pinside/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)


Expand Down Expand Up @@ -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",
Expand Down
6 changes: 5 additions & 1 deletion src/pinside/kicad/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
1 change: 1 addition & 0 deletions src/pinside/report.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
37 changes: 37 additions & 0 deletions tests/boards.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading
Loading