From f63c1c341105619f354307824f70fe32e823b760 Mon Sep 17 00:00:00 2001 From: Michele Bigi Date: Sun, 20 Sep 2026 08:58:54 +0200 Subject: [PATCH] Tighten PCB findings: actions, stitch gating, real copper. Fill action on every finding and show it in the report. Stitch/return only on large HS/power nets. Parse stackup thickness, arcs, and vias from the board. IC courtyard pad copper is not PE-PLC-004 RULE. Recommended RC/cap stay REVIEW. --- backend/periscopex/finding_engine.py | 35 ++++-- backend/periscopex/parsers_kicad_pcb.py | 38 +++++- backend/periscopex/pcb_power_thermal.py | 116 ++++++++++++++---- backend/periscopex/placement_check.py | 29 ++++- .../src/components/report/finding-card.tsx | 14 ++- tests/test_finding_engine.py | 23 ++++ tests/test_kicad_pcb.py | 22 ++++ tests/test_pcb_review.py | 54 ++++++-- tests/test_placement_check.py | 54 ++++++++ 9 files changed, 326 insertions(+), 59 deletions(-) diff --git a/backend/periscopex/finding_engine.py b/backend/periscopex/finding_engine.py index 49350d5..2ea9155 100644 --- a/backend/periscopex/finding_engine.py +++ b/backend/periscopex/finding_engine.py @@ -18,7 +18,10 @@ Provenance = Literal["MANDATORY", "RECOMMENDED", "TYPICAL", "EXAMPLE"] FindingClass = Literal["RULE", "RISK", "REVIEW", "INFO"] EvidenceStatus = Literal["SUFFICIENT", "INSUFFICIENT"] -_LLM_SOURCES = frozenset({None, "review", "pcb_review"}) +_DEFAULT_ACTION = ( + "Review this finding against the datasheet and the board, then change " + "the design if it applies." +) _SOFT_PROVENANCE = frozenset({"RECOMMENDED", "TYPICAL", "EXAMPLE"}) @@ -71,17 +74,20 @@ def _seed() -> None: ("PE-MUX-001", "Pin function must match the mux / datasheet pin table."), ("PE-NC-001", "NC pins must not be connected."), ("PE-DEC-001", "Supply pin requires a decoupling capacitor on that net."), - ("PE-DEC-002", "Decoupling capacitor value vs datasheet."), ("PE-I2C-001", "I2C bus requires pull-ups."), - ("PE-I2C-002", "I2C pull-up value vs datasheet."), ("PE-RST-001", "Reset pin requires the specified pull."), - ("PE-RST-002", "Reset pull value vs datasheet."), ("PE-BOM-001", "BOM MPN must match the netlist part."), ("PE-BOM-002", "BOM line missing from the netlist."), ("PE-DNP-001", "DNP / fitted state vs netlist."), ("PE-DRT-001", "Operating voltage must not exceed the capacitor rating."), ): _add(rid, "MANDATORY", "RULE", domain="schema", requirement=req) + for rid, req in ( + ("PE-DEC-002", "Decoupling capacitor value vs datasheet (recommended)."), + ("PE-I2C-002", "I2C pull-up value vs datasheet (recommended)."), + ("PE-RST-002", "Reset pull value vs datasheet (recommended)."), + ): + _add(rid, "RECOMMENDED", "REVIEW", domain="schema", requirement=req) for rid in ("PE-TH-001", "PE-TH-003"): _add(rid, "TYPICAL", "RISK", domain="schema", requirement="Thermal estimate only with I_load and θJA from specs.") @@ -93,12 +99,12 @@ def _seed() -> None: requirement="Trace width × copper thickness vs I_load (IPC-2221 when ΔT known).") for rid, prov, cls, req in ( ("PE-SEQ-001", "RECOMMENDED", "RISK", "Power sequencing from datasheet notes."), - ("PE-FLT-001", "RECOMMENDED", "RISK", "Filter topology vs datasheet."), - ("PE-FLT-002", "RECOMMENDED", "RISK", "Filter component vs datasheet."), - ("PE-FLT-003", "RECOMMENDED", "RISK", "Filter cutoff vs datasheet."), + ("PE-FLT-001", "RECOMMENDED", "REVIEW", "Filter topology vs datasheet."), + ("PE-FLT-002", "RECOMMENDED", "REVIEW", "Filter component vs datasheet."), + ("PE-FLT-003", "RECOMMENDED", "REVIEW", "Filter cutoff vs datasheet."), ("PE-XTAL-001", "MANDATORY", "RULE", "Crystal load capacitance vs Cl."), - ("PE-XTAL-002", "RECOMMENDED", "RISK", "Crystal layout / load-cap note."), - ("PE-XTAL-003", "RECOMMENDED", "RISK", "Crystal drive / Cl note."), + ("PE-XTAL-002", "RECOMMENDED", "REVIEW", "Crystal layout / load-cap note."), + ("PE-XTAL-003", "RECOMMENDED", "REVIEW", "Crystal drive / Cl note."), ("PE-LF-001", "TYPICAL", "INFO", "Lifecycle / NRND."), ("PE-LF-002", "TYPICAL", "INFO", "Lifecycle / last-time-buy."), ("PE-LF-003", "TYPICAL", "INFO", "Lifecycle / obsolete."), @@ -118,10 +124,11 @@ def _seed() -> None: ("PE-PLC-001", "Decoupling proximity max_distance_mm from layout_rules."), ("PE-PLC-002", "Thermal via min_via_count from layout_rules."), ("PE-PLC-003", "same_layer decoupling from layout_rules."), - ("PE-PLC-004", "Keepout from layout_rules."), ("PE-SI-001", "Differential skew vs length_match mm."), ): _add(rid, "MANDATORY", "RULE", domain="pcb", requirement=req) + _add("PE-PLC-004", "RECOMMENDED", "REVIEW", domain="pcb", + requirement="Keepout from layout_rules — not a DRC for pad copper.") _add("PE-VIA-001", "TYPICAL", "INFO", domain="pcb", requirement="Via current share from I_load and via count (no invented table).") _add("PE-THM-001", "RECOMMENDED", "RISK", domain="pcb", @@ -160,8 +167,8 @@ def complete_finding(f: Finding) -> Finding: if not (f.requirement or "").strip(): f.requirement = (rec.requirement if rec and rec.requirement else None) or f.why if not (f.action or "").strip(): - f.action = f.recommendation or "" - if not (f.recommendation or "").strip() and f.action: + f.action = (f.recommendation or "").strip() or _DEFAULT_ACTION + if not (f.recommendation or "").strip(): f.recommendation = f.action llm = (f.source in {None, "review", "pcb_review"}) and not f.rule_id @@ -219,6 +226,10 @@ def complete_finding(f: Finding) -> Finding: f.why = f.requirement if not (f.finding or "").strip(): f.finding = f.facts + if not (f.action or "").strip(): + f.action = _DEFAULT_ACTION + if not (f.recommendation or "").strip(): + f.recommendation = f.action return f diff --git a/backend/periscopex/parsers_kicad_pcb.py b/backend/periscopex/parsers_kicad_pcb.py index 2308153..73e05b8 100644 --- a/backend/periscopex/parsers_kicad_pcb.py +++ b/backend/periscopex/parsers_kicad_pcb.py @@ -5,6 +5,7 @@ No SI/DRC. Schema validation stays complete without this file. from __future__ import annotations +import re from pathlib import Path from backend.periscopex.models import ( @@ -29,6 +30,24 @@ from backend.periscopex.parsers_kicad import ( ) +def _copper_thickness_token(raw: object) -> float | None: + """Parse KiCad stackup thickness; units mm/um/mil. Never default 1 oz.""" + if isinstance(raw, (int, float)) and not isinstance(raw, bool): + v = float(raw) + return v if v > 0 else None + s = str(raw or "").strip().lower().replace(" ", "") + m = re.match(r"^([0-9]*\.?[0-9]+)(mm|um|µm|mil)?", s) + if not m: + return None + n = float(m.group(1)) + unit = m.group(2) or "mm" + if unit in {"um", "µm"}: + n = n / 1000.0 + elif unit == "mil": + n = n * 0.0254 + return n if n > 0 else None + + def _xy(node: object, name: str) -> tuple[float, float]: k = _kid(node, name) if not k or len(k) < 3: @@ -128,7 +147,11 @@ def _parse_stackup(tree: object) -> LayoutStackup | None: name = str(layer[1]) if len(layer) > 1 and not isinstance(layer[1], list) else "" kind = _layer_type(layer) thick = _kid(layer, "thickness") - height = _fnum(thick[1]) if thick and len(thick) > 1 else None + height = None + if thick and len(thick) > 1: + height = _copper_thickness_token(thick[1]) + if height is None and len(thick) > 2: + height = _copper_thickness_token(f"{thick[1]}{thick[2]}") if kind == "copper" or name.endswith(".Cu"): if name: copper.append(name) @@ -147,9 +170,9 @@ def _parse_stackup(tree: object) -> LayoutStackup | None: er=er, height_mm=height, )) - if len(copper) < 2 or len(dielectrics) != len(copper) - 1: - return None t = thicknesses[0] if thicknesses else None + if not copper and t is None: + return None return LayoutStackup( copper_layers=copper, dielectrics=dielectrics, @@ -291,6 +314,15 @@ def parse_kicad_pcb(path: str | Path) -> LayoutGraph: net=_net_name(node, nets), )) continue + if tag == "arc": + segments.append(LayoutSegment( + start=_xy(node, "start"), + end=_xy(node, "end"), + width=_fnum(_val(node, "width") or 0), + layer=_val(node, "layer"), + net=_net_name(node, nets), + )) + continue if tag == "via": drill_el = _kid(node, "drill") drill = _fnum(drill_el[1]) if drill_el and len(drill_el) > 1 else None diff --git a/backend/periscopex/pcb_power_thermal.py b/backend/periscopex/pcb_power_thermal.py index ac086cf..500eeb0 100644 --- a/backend/periscopex/pcb_power_thermal.py +++ b/backend/periscopex/pcb_power_thermal.py @@ -37,6 +37,12 @@ _IPC_K_EXT = 0.048 _IPC_K_INT = 0.024 _TJMAX_KEYS = ("tj_max", "tj_max_c", "tjmax", "max_junction_temp_c", "t_jmax") _SENSE_RE = re.compile(r"(kelvin|sense|isns|i_sns|cs\+|cs-|iout_sns)", re.I) +_HS_NET_RE = re.compile( + r"(USB|DP|DM|D\+|D-|HS|HDMI|MIPI|DDR|CLK|XTAL|HFX|LFX|SWP|DIFF)", + re.I, +) +_STITCH_MIN_SPAN_MM = 12.0 +_STITCH_MIN_AREA_MM2 = 40.0 def _mm_to_mil(mm: float) -> float: @@ -75,6 +81,17 @@ def _via_stats(layout: LayoutGraph, sch_net: str) -> tuple[int, float | None]: return n, (min(drills) if drills else None) +def _footprint_region(fp) -> list[tuple[float, float]]: + if len(fp.courtyard) >= 3: + return fp.courtyard + xs = [fp.x] + [p.x for p in fp.pads] + ys = [fp.y] + [p.y for p in fp.pads] + pad = 1.5 + xmin, xmax = min(xs) - pad, max(xs) + pad + ymin, ymax = min(ys) - pad, max(ys) + pad + return [(xmin, ymin), (xmax, ymin), (xmax, ymax), (xmin, ymax)] + + def _external_layers(layers: list[str]) -> bool: if not layers: return True @@ -223,28 +240,36 @@ def check_pcb_via_current( if key in seen: continue seen.add(key) - n, drill = _via_stats(layout, net) - if n <= 0: + n, _drill = _via_stats(layout, net) + if n > 0: + # Geometry is present; no via-ampacity table in the library — do not + # invent a rating or claim the vias were not parsed. continue - per = i_load / n out.append(Finding( designator=ref, mpn=comp.mpn or "", aspect="layout_power", finding=( - f"{net} shares I_load={i_load:.3g} A across {n} via(s) " - f"(≈{per:.3g} A each" - + (f", min drill {drill:g} mm" if drill else "") - + "). No via current rating in the library extraction." + f"{net} carries I_load={i_load:.3g} A with no vias on that net " + "in the parsed .kicad_pcb." ), - why="Via current is reported from board geometry + datasheet I_load only.", + why="Via count is taken from board vias whose net matches this supply.", status="INFO", recommendation=( - "If the datasheet or stackup vendor quotes via current, add more/larger vias " - f"so each via stays under that rating at {i_load:.3g} A." + f"Add vias on {net} if the current leaves this layer, then re-run PCB review." + ), + action=( + f"Add vias on {net} if the current leaves this layer, then re-run PCB review." ), source="pcb_power_thermal", rule_id="PE-VIA-001", + evidence_status="SUFFICIENT", + facts=( + f"I_load={i_load:.3g} A on {net}; 0 vias with a matching net " + f"in the PCB file (board has {len(layout.vias)} via(s) total)." + ), + requirement="No datasheet via current; report missing vias only.", + inference="Ampacity of vias is not judged without a rating.", net=net, pins=[], )) @@ -276,12 +301,15 @@ def check_pcb_thermal_copper( continue p = i_load * (vin - vout) fp = layout.footprints.get(ref) - if not fp or len(fp.courtyard) < 3: + if not fp: + continue + region = _footprint_region(fp) + if len(region) < 3: continue theta = _first(values, _THETA_KEYS) via_n = 0 for v in layout.vias: - if _in_poly(v.x, v.y, fp.courtyard): + if _in_poly(v.x, v.y, region): via_n += 1 pour = False for z in layout.zones: @@ -296,7 +324,6 @@ def check_pcb_thermal_copper( ): pass else: - # GND by name leaf = normalize_kicad_hierarchy_net(z.net).upper() if not (leaf == "GND" or leaf.endswith("/GND") or "GND" in leaf): continue @@ -306,26 +333,39 @@ def check_pcb_thermal_copper( break if via_n or pour: continue + rec = ( + "Add a copper pour and/or thermal vias under the package as the datasheet " + "layout page specifies, then re-run PCB review." + ) out.append(Finding( designator=ref, mpn=comp.mpn or "", aspect="layout_thermal", finding=( - f"{ref} dissipates ≈{p:.3g} W (I_load={i_load:.3g} A) but the courtyard " - "has no thermal vias and no overlapping copper pour." + f"{ref} dissipates ≈{p:.3g} W (I_load={i_load:.3g} A) but the package " + f"region has no thermal vias ({via_n}) and no overlapping copper pour " + f"(board vias parsed: {len(layout.vias)})." ), + facts=( + f"P≈{p:.3g} W; {via_n} vias in package region; pour={pour}; " + f"{len(layout.vias)} vias on the board." + ), + requirement=( + "Datasheet layout notes for copper/vias under the package " + "(recommended, not a shall)." + ), + inference="No pour/vias under the package on the parsed board.", why=( "P = I_load×(Vin−Vout) from the shared extraction/specs. " + (f"θJA={theta:.3g} °C/W. " if theta else "θJA missing. ") + "No millimetres invented." ), status="WARNING", - recommendation=( - "Add a copper pour and/or thermal vias under the package as the datasheet " - "layout page specifies, then re-run PCB review." - ), + recommendation=rec, + action=rec, source="pcb_power_thermal", rule_id="PE-THM-001", + evidence_status="SUFFICIENT" if layout.vias or layout.zones else "INSUFFICIENT", net=vout_n, pins=[], )) @@ -337,11 +377,24 @@ def _is_gnd_name(name: str) -> bool: return leaf in {"GND", "AGND", "DGND", "PGND", "VSS"} or leaf.endswith("/GND") +def _is_stitch_candidate(net_name: str, net, dx: float, dy: float) -> bool: + """High-speed / power nets, or a large bbox — never tiny GPIO stubs.""" + span = max(dx, dy) + area = dx * dy + if span < _STITCH_MIN_SPAN_MM or area < _STITCH_MIN_AREA_MM2: + return False + if net.net_type == NetType.POWER: + return True + if _HS_NET_RE.search(net_name or ""): + return True + return False + + def check_pcb_gnd_stitch( graph: DesignGraph, layout: LayoutGraph | None, ) -> list[Finding]: - """INFO when a signal span has a GND pour but no GND via in its bbox.""" + """INFO when a long HS/power span has a GND pour but no GND via in its bbox.""" if layout is None: return [] gnd_zones = [ @@ -357,7 +410,7 @@ def check_pcb_gnd_stitch( out: list[Finding] = [] seen: set[str] = set() for net_name, net in sorted(graph.nets.items()): - if net.net_type != NetType.SIGNAL: + if net.net_type not in (NetType.SIGNAL, NetType.POWER): continue key = normalize_kicad_hierarchy_net(net_name) if key in seen: @@ -375,30 +428,39 @@ def check_pcb_gnd_stitch( ys.extend([s.start[1], s.end[1]]) xmin, xmax = min(xs), max(xs) ymin, ymax = min(ys), max(ys) - if xmax - xmin < 2.0 and ymax - ymin < 2.0: + dx, dy = xmax - xmin, ymax - ymin + if not _is_stitch_candidate(net_name, net, dx, dy): continue if any(xmin <= v.x <= xmax and ymin <= v.y <= ymax for v in gnd_vias): continue seen.add(key) refs = [p.component_ref for p in net.pins[:1]] ref = refs[0] if refs else net_name + rec = ( + f"Add GND stitch vias along '{net_name}' so the return current " + "has a nearby via to the ground plane." + ) out.append(Finding( designator=ref, mpn=(graph.components[ref].mpn if ref in graph.components else "") or "", aspect="layout_return", finding=( - f"Signal '{net_name}' spans {xmax - xmin:.1f}×{ymax - ymin:.1f} mm " + f"Net '{net_name}' spans {dx:.1f}×{dy:.1f} mm " "over a GND pour with no GND via in that bounding box." ), + facts=( + f"BBox {dx:.1f}×{dy:.1f} mm on '{net_name}'; " + f"{len(gnd_vias)} GND via(s) on the board, none in bbox." + ), + requirement="Return current needs a nearby GND via on long HS/power routes.", + inference="No GND via in the span — stitch if the return path should stay local.", why=( "Return path / stitch vias are inferred from board zones and vias only " "(no IEC clearance invented)." ), status="INFO", - recommendation=( - f"Add GND stitch vias along '{net_name}' so the return current " - "has a nearby via to the ground plane." - ), + recommendation=rec, + action=rec, source="pcb_power_thermal", rule_id="PE-STCH-001", net=net_name, diff --git a/backend/periscopex/placement_check.py b/backend/periscopex/placement_check.py index b582295..f824dd7 100644 --- a/backend/periscopex/placement_check.py +++ b/backend/periscopex/placement_check.py @@ -289,9 +289,15 @@ def _keepout_finding(ref, comp, cons, rule, graph: DesignGraph, layout: LayoutGr own = _net_for_pin(graph, ref, pin_no) if pin_no else None if not own: return [] + pad_nets = [ + p.net for p in fp.pads + if p.net + ] foreign: list[str] = [] for s in layout.segments: - if not s.net or s.net == own: + if not s.net or kicad_nets_match(s.net, own): + continue + if any(kicad_nets_match(s.net, pn) for pn in pad_nets): continue if _in_poly(s.start[0], s.start[1], fp.courtyard) or _in_poly( s.end[0], s.end[1], fp.courtyard @@ -300,17 +306,32 @@ def _keepout_finding(ref, comp, cons, rule, graph: DesignGraph, layout: LayoutGr if not foreign: return [] net = sorted(set(foreign))[0] + is_crystal = comp.component_type == ComponentType.CRYSTAL + rec = ( + "Keep unrelated nets out of the keepout if the datasheet shows a " + "keep-out zone; traces to this part's own pads are normal copper." + ) return [Finding( designator=ref, mpn=comp.mpn or cons.mpn, aspect="placement", finding=f"Track on {net} enters courtyard of {ref} (keepout on {own}).", + facts=f"Segment on '{net}' has an endpoint inside {ref} courtyard.", + requirement="layout_rules kind=keepout (recommended courtyard isolation).", + inference=( + "Crystal keepout may apply." + if is_crystal + else "Not a pad net of this footprint — review, not a mandatory DRC." + ), why="layout_rules kind=keepout.", - status="WARNING", - recommendation="Keep other nets out of the courtyard.", + status="WARNING" if is_crystal else "INFO", + recommendation=rec, + action=rec, source="placement_check", rule_id="PE-PLC-004", + finding_class="REVIEW", + provenance="RECOMMENDED", net=net, - pins=[pin_no], + pins=[pin_no] if pin_no else [], source_page=rule.get("source_page"), )] diff --git a/frontend/src/components/report/finding-card.tsx b/frontend/src/components/report/finding-card.tsx index c1b3492..716d613 100644 --- a/frontend/src/components/report/finding-card.tsx +++ b/frontend/src/components/report/finding-card.tsx @@ -58,8 +58,9 @@ export function FindingCard({ const [open, setOpen] = useState(defaultOpen ?? false); const commentCount = comments?.length ?? 0; const hasCommentSupport = !!(projectId && collaborators && onCommentAdded && onCommentDeleted); + const actionText = (finding.action || finding.recommendation || "").trim(); const expandable = - !!finding.recommendation || + !!actionText || !!finding.facts || !!finding.requirement || !!finding.inference || @@ -116,17 +117,17 @@ export function FindingCard({ )} {finding.finding}

- {finding.why && ( + {finding.why && !finding.facts && !finding.requirement && (

{finding.why}

)}
- {finding.recommendation && ( + {actionText && ( - Recommendation + Action )} {hasCommentSupport && finding.finding_id && ( @@ -249,9 +250,10 @@ export function FindingCard({ ) : null} )} - {finding.recommendation && ( + {actionText && (

- {finding.recommendation} + Action. + {actionText}

)} {hasCommentSupport && finding.finding_id && ( diff --git a/tests/test_finding_engine.py b/tests/test_finding_engine.py index 81f2c3e..f5948df 100644 --- a/tests/test_finding_engine.py +++ b/tests/test_finding_engine.py @@ -133,3 +133,26 @@ def test_same_object_pcb_and_schema(): assert obj.finding_class assert obj.provenance assert obj.evidence_status + assert (obj.action or "").strip() + + +def test_empty_recommendation_gets_action(): + f = Finding(designator="U1", finding="note", status="INFO", source="review") + complete_finding(f) + assert f.action.strip() + assert f.recommendation.strip() == f.action.strip() + + +def test_recommended_rc_cap_is_review_not_rule(): + f = Finding( + designator="C1", + finding="10n vs 100n", + why="datasheet typical 100n", + status="WARNING", + rule_id="PE-DEC-002", + source="passive_rail_check", + ) + complete_finding(f) + assert f.finding_class == "REVIEW" + assert f.provenance == "RECOMMENDED" + assert f.status != "ERROR" diff --git a/tests/test_kicad_pcb.py b/tests/test_kicad_pcb.py index 7d9737c..d5424e8 100644 --- a/tests/test_kicad_pcb.py +++ b/tests/test_kicad_pcb.py @@ -237,3 +237,25 @@ def test_layout_graph_written_from_valid_pcb(tmp_path: Path): assert out.is_file() data = __import__("json").loads(out.read_text()) assert "R1" in data["footprints"] + + +def test_stackup_thickness_without_full_dielectrics(tmp_path: Path): + p = tmp_path / "t.kicad_pcb" + p.write_text("""(kicad_pcb (version 20240108) + (net 0 "") + (net 1 "VOUT") + (setup + (stackup + (layer "F.Cu" (type "copper") (thickness 0.035mm)) + (layer "B.Cu" (type "copper") (thickness 0.035)) + ) + ) + (via (at 1 1) (size 0.8) (drill 0.4) (layers "F.Cu" "B.Cu") (net 1)) + (arc (start 0 0) (end 2 0) (width 0.4) (layer "F.Cu") (net 1)) +) +""") + g = parse_kicad_pcb(p) + assert g.stackup is not None + assert g.stackup.copper_thickness_mm == pytest.approx(0.035) + assert len(g.vias) == 1 + assert any(s.width == pytest.approx(0.4) and s.net == "VOUT" for s in g.segments) diff --git a/tests/test_pcb_review.py b/tests/test_pcb_review.py index bd79f1a..43090f2 100644 --- a/tests/test_pcb_review.py +++ b/tests/test_pcb_review.py @@ -394,12 +394,19 @@ def test_power_trace_ipc2221_errors_when_load_exceeds_ampacity(): assert findings[0].recommendation +def test_power_trace_uses_parsed_width_and_thickness(): + from backend.periscopex.pcb_power_thermal import check_pcb_power_traces + + graph, cmap, layout = _ldo_graph_layout(i_load=0.05, width=1.0, thickness=0.035) + findings = check_pcb_power_traces(graph, cmap, layout) + assert findings == [] + + def test_power_trace_skips_without_i_load(): from backend.periscopex.pcb_power_thermal import check_pcb_power_traces graph, cmap, layout = _ldo_graph_layout(i_load=10) graph.components["U1"].specs.values["i_load_a"] = None # type: ignore[union-attr] - # drop i_load graph.components["U1"].specs = None assert check_pcb_power_traces(graph, cmap, layout) == [] @@ -502,6 +509,38 @@ def test_thermal_copper_warns_without_pour_or_vias(): assert findings[0].recommendation +def test_gnd_stitch_skips_tiny_gpio(): + from backend.periscopex.models import LayoutZone, PinConnection + from backend.periscopex.pcb_power_thermal import check_pcb_gnd_stitch + + graph = DesignGraph( + components={ + "U1": Component( + reference="U1", value="", footprint="", + component_type=ComponentType.IC, mpn="X", + pins={"1": "GPIO4"}, + ), + }, + nets={ + "GPIO4": Net( + name="GPIO4", net_type=NetType.SIGNAL, + pins=[PinConnection(component_ref="U1", pin_number="1")], + ), + }, + ) + layout = LayoutGraph( + footprints={}, + segments=[ + LayoutSegment(start=(0, 0), end=(0.1, 3), width=0.2, layer="F.Cu", net="GPIO4"), + ], + zones=[ + LayoutZone(net="GND", layer="B.Cu", outlines=[[(0, -5), (20, -5), (20, 5), (0, 5)]]), + ], + vias=[], + ) + assert check_pcb_gnd_stitch(graph, layout) == [] + + def test_gnd_stitch_info_when_signal_has_pour_but_no_via(): from backend.periscopex.models import LayoutVia, LayoutZone, PinConnection from backend.periscopex.pcb_power_thermal import check_pcb_gnd_stitch @@ -511,12 +550,12 @@ def test_gnd_stitch_info_when_signal_has_pour_but_no_via(): "U1": Component( reference="U1", value="", footprint="", component_type=ComponentType.IC, mpn="X", - pins={"1": "SDA"}, + pins={"1": "USB_DP"}, ), }, nets={ - "SDA": Net( - name="SDA", net_type=NetType.SIGNAL, + "USB_DP": Net( + name="USB_DP", net_type=NetType.SIGNAL, pins=[PinConnection(component_ref="U1", pin_number="1")], ), }, @@ -524,17 +563,18 @@ def test_gnd_stitch_info_when_signal_has_pour_but_no_via(): layout = LayoutGraph( footprints={}, segments=[ - LayoutSegment(start=(0, 0), end=(20, 0), width=0.2, layer="F.Cu", net="SDA"), + LayoutSegment(start=(0, 0), end=(20, 8), width=0.2, layer="F.Cu", net="USB_DP"), ], zones=[ - LayoutZone(net="GND", layer="B.Cu", outlines=[[(0, -5), (20, -5), (20, 5), (0, 5)]]), + LayoutZone(net="GND", layer="B.Cu", outlines=[[(0, -5), (20, -5), (20, 10), (0, 10)]]), ], vias=[], ) findings = check_pcb_gnd_stitch(graph, layout) assert findings and findings[0].rule_id == "PE-STCH-001" assert findings[0].status == "INFO" - layout.vias = [LayoutVia(x=10, y=0, net="GND", drill=0.3)] + assert findings[0].action + layout.vias = [LayoutVia(x=10, y=4, net="GND", drill=0.3)] assert check_pcb_gnd_stitch(graph, layout) == [] diff --git a/tests/test_placement_check.py b/tests/test_placement_check.py index 637b001..9bd2f06 100644 --- a/tests/test_placement_check.py +++ b/tests/test_placement_check.py @@ -262,6 +262,60 @@ def test_keepout_foreign_track_in_courtyard_is_ps_plc_004(): assert len(plc) == 1 assert plc[0].designator == "X1" assert plc[0].net == "GND" + assert plc[0].finding_class == "REVIEW" + assert plc[0].action + + +def test_ic_courtyard_pad_nets_are_not_keepout_violations(): + """GND/I2C into an IC courtyard is normal copper to that part's pads.""" + from backend.periscopex.models import Component, ComponentType, Net, NetType, PinConnection + + cons = { + "MCU": ComponentConstraints( + mpn="MCU", + pintable=[Pin(number="1", name="SDA")], + absolute_maximum_ratings=[], + rules=[], + layout_rules=[{"kind": "keepout", "pin": "1"}], + ) + } + graph = DesignGraph( + components={ + "U1": Component( + reference="U1", value="", footprint="", + component_type=ComponentType.IC, mpn="MCU", + pins={"1": "SDA", "2": "GND"}, + ), + }, + nets={ + "SDA": Net( + name="SDA", net_type=NetType.SIGNAL, + pins=[PinConnection(component_ref="U1", pin_number="1")], + ), + "GND": Net( + name="GND", net_type=NetType.GROUND, + pins=[PinConnection(component_ref="U1", pin_number="2")], + ), + }, + ) + layout = LayoutGraph( + footprints={ + "U1": LayoutFootprint( + reference="U1", x=0, y=0, layer="F.Cu", + pads=[ + LayoutPad(number="1", x=0.2, y=0.2, net="SDA"), + LayoutPad(number="2", x=0.8, y=0.2, net="GND"), + ], + courtyard=[(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)], + ), + }, + segments=[ + LayoutSegment(start=(0.4, 0.4), end=(0.6, 0.4), width=0.2, layer="F.Cu", net="GND"), + LayoutSegment(start=(0.3, 0.5), end=(0.5, 0.5), width=0.2, layer="F.Cu", net="SDA"), + ], + ) + findings = check_placement(graph, cons, layout) + assert [f for f in findings if f.rule_id == "PE-PLC-004"] == [] def test_keepout_own_net_in_courtyard_is_silent():