mohan-bee/rc-link-breakaway-panel

A four-layer RC link panel combining an STM32/E28 2.4‑GHz transmitter with USB-RP2040 socket and an STM32/E28 receiver providing six buffered PWM servo outputs, regulated 3.3‑V power, headers, controls, and protection/decoupling.

Version
0.1.5
License
unset
Stars
0

tools/audit-panel.py

import contextlib, io, json, os, runpy
from pathlib import Path
from shapely.geometry import Polygon, Point, box
from shapely.affinity import translate
from shapely.ops import unary_union
from shapely.validation import make_valid

root = Path(__file__).resolve().parents[1]
def load(path, report):
    os.environ["PHONE_TX_AUDIT_CIRCUIT"] = str(path)
    os.environ["PHONE_TX_AUDIT_REPORT_DIR"] = str(root / "validation" / report)
    with contextlib.redirect_stdout(io.StringIO()):
        env = runpy.run_path(str(root / "tools/audit-copper.py"))
    shapes = env["shapes"]
    for e in env["c"]:
        if e["type"] != "pcb_copper_pour": continue
        b = e["brep_shape"]
        p = make_valid(Polygon([(v["x"],v["y"]) for v in b["outer_ring"]["vertices"]], [[(v["x"],v["y"]) for v in r["vertices"]] for r in b["inner_rings"]]))
        net=next(n['subcircuit_connectivity_map_key'] for n in env['c'] if n['type']=='source_net' and n['source_net_id']==e['source_net_id'])
        shapes.append(dict(geom=p, layers={e["layer"]}, kind="pour", net=net))
    return env["c"], shapes

c, actual = load(root / "dist/index/circuit.json", "panel")
layers = ["top", "inner1", "inner2", "bottom"]
actual_layers = {layer: unary_union([s["geom"] for s in actual if layer in s["layers"]]) for layer in layers}
results = {}
units = []
for name, dx, dy in [("transmitter", -23.5, 0), ("receiver", 36.5, -3)]:
    original, expected = load(root / "boards" / f"{name}.circuit.json", name)
    b = next(e for e in original if e["type"] == "pcb_board")
    outline = translate(Polygon([(v["x"], v["y"]) for v in b["outline"]]), dx, dy)
    units.append(outline)
    results[name] = {}
    # Only the receiver's servo supply and ground were intentionally rerouted.
    excluded_original={e['subcircuit_connectivity_map_key'] for e in original if e['type']=='source_net' and name=='receiver' and e['name'] in ['GND','V_SERVO']}
    excluded_actual={e['subcircuit_connectivity_map_key'] for e in c if e['type']=='source_net' and name=='receiver' and e['name'] in ['RX_GND','RX_V_SERVO']}
    for layer in layers:
        target = translate(unary_union([s["geom"] for s in expected if layer in s["layers"] and s['net'] not in excluded_original]), dx, dy)
        measured = unary_union([s['geom'] for s in actual if layer in s['layers'] and s['net'] not in excluded_actual]).intersection(outline)
        missing = target.difference(measured.buffer(.00001)).area
        added = measured.difference(target.buffer(.00001)).area
        results[name][layer] = dict(missing_mm2=missing, added_mm2=added)
        assert missing < .001 and added < .001, (name, layer, missing, added)

g = json.loads((root / "panel-geometry.json").read_text())
for layer, copper in actual_layers.items():
    outside = copper.difference(unary_union(units))
    fiducials = unary_union([Point(p['x'],p['y']).buffer(.501) for p in g['fiducials']]) if layer == 'top' else Polygon()
    assert outside.difference(fiducials).area < .00001, (layer, 'copper crosses a breakaway tab or rail')
slots = unary_union([Polygon([(p["x"],p["y"]) for p in pts]) for pts in g["slots"]])
material = box(-66.5,-38,66.5,38).difference(slots)
assert material.geom_type == "Polygon", "Panel must be one connected piece"
for hole in (e for e in c if e["type"] == "pcb_hole"):
    material = material.difference(Point(hole["x"], hole["y"]).buffer(hole["hole_diameter"]/2))
assert material.geom_type == "Polygon", "Mouse bites must leave connecting webs"
slot_gap = {layer: slots.distance(copper) for layer, copper in actual_layers.items()}
assert min(slot_gap.values()) >= .499, slot_gap
hole_gap = min(Point(p["x"],p["y"]).distance(copper) - .3 for p in g["mouseBites"] for copper in actual_layers.values())
assert hole_gap >= .249, hole_gap
assert len([e for e in c if e["type"] == "pcb_hole"]) == 96
assert len([e for e in c if e["type"] == "pcb_plated_hole"]) == 54
assert len([e for e in c if e["type"] == "pcb_via"]) == 268
for e in c:
    if e["type"] in ["pcb_plated_hole", "pcb_via"]:
        assert set(e["layers"]) == set(layers), e
report = dict(status="pass", preserved_copper=results, comparison_scope={'transmitter':'all copper','receiver':'all copper except intentionally rerouted RX_GND and RX_V_SERVO; these are verified by servo-power-audit and copper-connectivity'}, router_slot_copper_gap_mm=slot_gap,
              mouse_bite_copper_gap_mm=hole_gap, npth_count=96, pth_count=322,
              connected_before_clipping=True, scope="Geometry and connectivity only; no physical hardware qualification")
(root / "validation/panel-audit.json").write_text(json.dumps(report, indent=2)+"\n")
print(json.dumps(report, indent=2))