From c6abdae762a4ac7141261426bca748a93477ebeb Mon Sep 17 00:00:00 2001 From: Michele Bigi Date: Mon, 21 Sep 2026 12:11:43 +0200 Subject: [PATCH] Skip PE-PWR/PE-VIA without datasheet current (2.61.1). Trace-width vs current runs only with I_load/Imax/I_abs. Missing I is a skip, not INFO INSUFFICIENT, not USB 500 mA, not Iout_max-as-load. --- .../src/backend/periscopex/finding_engine.py | 10 ++- .../periscopex/interface_class_check.py | 3 +- .../backend/periscopex/pcb_power_thermal.py | 84 ++++++++----------- .../src/backend/periscopex/pcb_review.py | 3 +- .../src/backend/periscopex/review_parse.py | 2 +- .../src/docs/interface-class-certifiers.md | 4 +- periscope/src/docs/piano-pcb-review.md | 6 +- periscope/src/frontend/content/changelog.md | 7 ++ periscope/src/frontend/package.json | 2 +- tests/test_interface_class_check.py | 13 +++ tests/test_pcb_review.py | 37 +++++++- tests/test_pcb_software_bugs.py | 8 +- 12 files changed, 115 insertions(+), 64 deletions(-) diff --git a/periscope/src/backend/periscopex/finding_engine.py b/periscope/src/backend/periscopex/finding_engine.py index cd1fa83..ca3f689 100644 --- a/periscope/src/backend/periscopex/finding_engine.py +++ b/periscope/src/backend/periscopex/finding_engine.py @@ -102,7 +102,10 @@ def _seed() -> None: requirement="I_load vs regulator capability (not Iout_max).") _add("PE-PWR-001", "MANDATORY", "RULE", domain="pcb", source="pcb_power_thermal", - requirement="Trace width × copper thickness vs I_load (IPC-2221 when ΔT known).") + requirement=( + "Trace width × copper vs datasheet I_load/Imax/I_abs " + "(IPC-2221 when ΔT known). Skip if that current is missing." + )) for rid, prov, cls, req in ( ("PE-SEQ-001", "RECOMMENDED", "RISK", "Power sequencing from datasheet notes."), ("PE-FLT-001", "RECOMMENDED", "REVIEW", "Filter topology vs datasheet."), @@ -152,7 +155,10 @@ def _seed() -> None: _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).") + requirement=( + "Via count on a net with datasheet I_load/Imax/I_abs " + "(no invented ampacity table). Skip if that current is missing." + )) _add("PE-THM-001", "RECOMMENDED", "RISK", domain="pcb", requirement="Dissipation vs courtyard pour/vias when P is known.") _add("PE-KEL-001", "RECOMMENDED", "RISK", domain="pcb", diff --git a/periscope/src/backend/periscopex/interface_class_check.py b/periscope/src/backend/periscopex/interface_class_check.py index 4facbde..1d44c5b 100644 --- a/periscope/src/backend/periscopex/interface_class_check.py +++ b/periscope/src/backend/periscopex/interface_class_check.py @@ -2,7 +2,8 @@ Not generic SI. Not via ampacity. Not thermal FEM. Graph connectivity only: class from BOM / footprint / net FACT, then connection integrity. Missing -numbers are INSUFFICIENT_EVIDENCE. A bare RJ45 is not PoE. +numbers are INSUFFICIENT_EVIDENCE. A bare RJ45 is not PoE. No invented +VBUS/Ethernet current (PE-PWR skips when the datasheet has no I). """ from __future__ import annotations diff --git a/periscope/src/backend/periscopex/pcb_power_thermal.py b/periscope/src/backend/periscopex/pcb_power_thermal.py index 09bbc59..e4e3a58 100644 --- a/periscope/src/backend/periscopex/pcb_power_thermal.py +++ b/periscope/src/backend/periscopex/pcb_power_thermal.py @@ -1,6 +1,8 @@ """Power-trace, via, thermal-copper, and Kelvin checks on a LayoutGraph. -Skip when current, width, copper thickness, or datasheet numbers are missing. +Trace-width vs current runs only if the datasheet reports I_load / Imax / I_abs. +Missing that number → skip (no invented I, no USB 500 mA, no INFO INSUFFICIENT). +Iout_max is a rating, not load. Via ampacity is never invented. No thermal FEM. IPC-2221 is cited only when ΔT can be taken from datasheet Tjmax − 25 °C. """ @@ -30,6 +32,14 @@ from backend.periscopex.thermal_check import ( ) from backend.periscopex.constraints_lookup import match_constraints as _match_constraints +# Width/via current: I_load, Imax, or I_abs from the datasheet. Never Iout_max. +_TRACE_CURRENT_KEYS = _LOAD_KEYS + ( + "i_max", "i_max_a", "imax", "imax_a", + "i_abs", "i_abs_a", "iabs", + "i_abs_max", "i_abs_max_a", + "absolute_maximum_current_a", +) + # IPC-2221 §6.2 (empirical): I = k · ΔT^0.44 · A^0.725, A in mil², I in A. _IPC_B = 0.44 _IPC_C = 0.725 @@ -105,18 +115,25 @@ def _external_layers(layers: list[str]) -> bool: ) +def _datasheet_trace_current(comp) -> float | None: + """I_load / Imax / I_abs from specs. None if absent or ≤0. Never Iout_max.""" + i = _first(_specs_values(comp), _TRACE_CURRENT_KEYS) + if i is None or i <= 0: + return None + return i + + def _load_on_output( graph: DesignGraph, cmap: dict, ref: str, ) -> tuple[float, str, str] | None: - """I_load (not Iout_max) on an IC output net, plus that net name.""" + """Datasheet current (not Iout_max) on an IC output net, plus that net name.""" comp = graph.components.get(ref) if not comp or comp.component_type != ComponentType.IC: return None - cons = _match_constraints(comp.mpn or comp.value, cmap) - values = _specs_values(comp) - i_load = _first(values, _LOAD_KEYS) + i_load = _datasheet_trace_current(comp) if i_load is None: return None + cons = _match_constraints(comp.mpn or comp.value, cmap) vout = _pin_net_by_role(graph, comp, cons, _VOUT_PIN) if not vout: return None @@ -128,7 +145,10 @@ def check_pcb_power_traces( constraints_map: dict, layout: LayoutGraph | None, ) -> list[Finding]: - """Trace width × copper thickness vs datasheet I_load (IPC-2221 when ΔT known).""" + """Trace width × copper vs datasheet I_load/Imax/I_abs (IPC-2221 when ΔT known). + + Missing datasheet current → skip. No invented I, no USB 500 mA, no INFO. + """ if layout is None: return [] t_mm = layout.stackup.copper_thickness_mm if layout.stackup else None @@ -137,7 +157,9 @@ def check_pcb_power_traces( for ref, comp in sorted(graph.components.items()): if comp.component_type != ComponentType.IC: continue - i_load = _first(_specs_values(comp), _LOAD_KEYS) + i_load = _datasheet_trace_current(comp) + if i_load is None: + continue cons = _match_constraints(comp.mpn or comp.value, constraints_map) vout = _pin_net_by_role(graph, comp, cons, _VOUT_PIN) if not vout: @@ -147,25 +169,6 @@ def check_pcb_power_traces( continue seen_nets.add(key) net = vout - if i_load is None or i_load <= 0: - out.append(Finding( - designator=ref, - mpn=comp.mpn or "", - aspect="layout_power", - finding=( - f"{net} has no datasheet I_load; IPC-2221 ampacity is not applied." - ), - why="I_load is required; width/current are never invented.", - status="INFO", - recommendation="Add I_load to the shared library extraction, then re-run PCB review.", - source="pcb_power_thermal", - rule_id="PE-PWR-001", - evidence_status="INSUFFICIENT", - assumptions=["No default current; IPC-2221 skipped."], - net=net, - pins=[], - )) - continue w_mm, layers = _min_width_layers(layout, net) if w_mm is None: continue @@ -230,7 +233,10 @@ def check_pcb_power_traces( f"{_IPC_K_EXT if ext else _IPC_K_INT}, ΔT={dt:.0f} °C, " f"w={w_mm:g} mm, t={t_mm * 1000:g} µm)." ), - why="IPC-2221 §6.2 I = k·ΔT^0.44·A^0.725. I_load is the datasheet/typical load, not Iout_max.", + why=( + "IPC-2221 §6.2 I = k·ΔT^0.44·A^0.725. " + "Current is datasheet I_load / Imax / I_abs, not Iout_max, not USB 500 mA." + ), status="ERROR", recommendation=( f"Widen {net} (and/or add copper/vias/planes) so ampacity ≥ {i_load:.3g} A, " @@ -253,7 +259,7 @@ def check_pcb_via_current( constraints_map: dict, layout: LayoutGraph | None, ) -> list[Finding]: - """Share I_load across vias; no invented via-ampacity table.""" + """Share datasheet I across vias; skip if I is missing. No invented via table.""" if layout is None: return [] out: list[Finding] = [] @@ -261,7 +267,9 @@ def check_pcb_via_current( for ref, comp in sorted(graph.components.items()): if comp.component_type != ComponentType.IC: continue - i_load = _first(_specs_values(comp), _LOAD_KEYS) + i_load = _datasheet_trace_current(comp) + if i_load is None: + continue cons = _match_constraints(comp.mpn or comp.value, constraints_map) net = _pin_net_by_role(graph, comp, cons, _VOUT_PIN) if not net: @@ -270,24 +278,6 @@ def check_pcb_via_current( if key in seen: continue seen.add(key) - if i_load is None or i_load <= 0: - out.append(Finding( - designator=ref, - mpn=comp.mpn or "", - aspect="layout_power", - finding=( - f"{net} has no datasheet I_load; via current is not rated." - ), - why="I_load is required; via ampacity is never invented.", - status="INFO", - recommendation="Add I_load to the shared library extraction, then re-run PCB review.", - source="pcb_power_thermal", - rule_id="PE-VIA-001", - evidence_status="INSUFFICIENT", - net=net, - pins=[], - )) - continue n, _drill = _via_stats(layout, net) if n > 0: # Geometry is present; no via-ampacity table in the library — do not diff --git a/periscope/src/backend/periscopex/pcb_review.py b/periscope/src/backend/periscopex/pcb_review.py index 31b9f73..122903c 100644 --- a/periscope/src/backend/periscopex/pcb_review.py +++ b/periscope/src/backend/periscopex/pcb_review.py @@ -39,7 +39,8 @@ Do not claim antenna keepout geometry is missing if keepout zones are listed. - Interface class: USB-C CC/Rd/VBUS, Ethernet 10/100 vs GbE magnetics, \ PoE only with evidence. Deterministic certifiers already ran; explain \ those FACTS. Never invent PoE on a bare RJ45 or SuperSpeed on USB2 Type-C. \ -Never invent millimetres, Z, or I. +Never invent millimetres, Z, or I. Do not assume USB 500 mA — PE-PWR \ +skips when the datasheet has no I_load/Imax/I_abs. ### Evidence Cite numbers from "Parsed board geometry". Say insufficient evidence ONLY \ diff --git a/periscope/src/backend/periscopex/review_parse.py b/periscope/src/backend/periscopex/review_parse.py index 2d06858..afb155a 100644 --- a/periscope/src/backend/periscopex/review_parse.py +++ b/periscope/src/backend/periscopex/review_parse.py @@ -43,7 +43,7 @@ compensation, feedback divider, sense resistor). - Dedicated interface class (USB-C CC1/CC2 Rp/Rd 5.1 kΩ VBUS/GND; Ethernet \ 10/100 vs GbE magnetics; PoE only with evidence). Deterministic certifiers \ own those FACTS. Do not invent PoE on a bare RJ45, SuperSpeed on USB 2.0 \ -Type-C, or geometry/Z/I. +Type-C, or geometry/Z/I. Do not assume USB 500 mA. Then work the areas one at a time. For EACH area, don't just confirm a \ part is present — ask what specific failure mode would make it wrong \ diff --git a/periscope/src/docs/interface-class-certifiers.md b/periscope/src/docs/interface-class-certifiers.md index a495870..fbe50ce 100644 --- a/periscope/src/docs/interface-class-certifiers.md +++ b/periscope/src/docs/interface-class-certifiers.md @@ -10,7 +10,7 @@ Non è un unico check SI generico. Non è il DRC KiCad. Non è “via ampacity I Classe dichiarata o inferita (FACT/REQUIREMENT da BOM, net, footprint, sheet). Se manca evidenza: INSUFFICIENT_EVIDENCE, niente geometria/Z/I inventati. Via ≠ pad ≠ track. -## Prima fetta (shipped 2.61.0) +## Prima fetta (shipped 2.61.0, skip-I 2.61.1) | ID | Cosa certifica | | --- | --- | @@ -27,7 +27,7 @@ Classe dichiarata o inferita (FACT/REQUIREMENT da BOM, net, footprint, sheet). S | PE-POE-002 | Classe/tipo 802.3af/at/bt se citati; altrimenti INSUFFICIENT | | PE-POE-003 | Isolamento/magnetics solo con evidenza PoE | -Motore: `periscopex/interface_class_check.py` (grafo, non geometria). Schema `MODE=run` e PCB `MODE=pcb`. L’IA spiega i FACT, non certifica. +Motore: `periscopex/interface_class_check.py` (grafo, non geometria). Schema `MODE=run` e PCB `MODE=pcb`. L’IA spiega i FACT, non certifica. Nessun I inventato (niente USB 500 mA): la corrente vs larghezza è PE-PWR, e **skip** se il datasheet non riporta I_load/Imax/I_abs. ## Dopo diff --git a/periscope/src/docs/piano-pcb-review.md b/periscope/src/docs/piano-pcb-review.md index 0368511..8ad89d4 100644 --- a/periscope/src/docs/piano-pcb-review.md +++ b/periscope/src/docs/piano-pcb-review.md @@ -27,7 +27,7 @@ Vedi `motore-finding.md`. Invarianti: 3. Classi **RULE | RISK | REVIEW | INFO**. LLM = REVIEW, mai RULE. 4. `decisions.json` + fingerprint: rescan non ri-naga (PSEL low intenzionale, ecc.). 5. `status`, `confidence`, `evidence_status` indipendenti. -6. Evidenza insufficiente → lo si dice, niente 1 oz / 10 °C / 50 Ω / IEC inventati. +6. Evidenza insufficiente → lo si dice, niente 1 oz / 10 °C / 50 Ω / IEC inventati. **Eccezione PE-PWR/PE-VIA:** se manca I_load/Imax/I_abs, skip silenzioso (niente INFO, niente 500 mA USB). 7. Oggetto riproducibile: facts, requirement, source, calculation, assumptions, confidence, severity, action. UI report (schema e layout): stessa `FindingCard` — Fact / Requirement / Inference, badge classe e provenance. @@ -58,7 +58,7 @@ Mutex vs analisi e vs placement. SSE `pcb_*`. IDs `PCB-{ref}-{001}`. KiCad `/net | PE-SI-002 | ImpedenceFinder Zavg/min/max vs `impedance` window | | PE-SI-003…009 | max length, spacing, ref plane, vias, return, series R — each a datasheet number | | PE-SI-010 | HS bus measured, library has no SI FACT (not 90 Ω folklore) | -| PE-PWR-001 | I_load + width; IPC solo con thickness e Tjmax | +| PE-PWR-001 | I_load/Imax/I_abs + width; **skip** se manca I (no 500 mA, no INFO); IPC solo con thickness e Tjmax | | PE-DRT-001 | Vop e Vrated, Vop > Vr → RULE ERROR | | PE-DRT-002 | 0.8 < Vop/Vr ≤ 1 → RISK MARGIN | | PE-DRT-003 | Vop/Vr ≤ 0.8 → PASS summary INFO | @@ -66,7 +66,7 @@ Mutex vs analisi e vs placement. SSE `pcb_*`. IDs `PCB-{ref}-{001}`. KiCad `/net | PE-TIM-001/002 | RC / strap solo con numeri | | PE-PI-001/002 | local vs bulk; mm solo da layout_rules | | PE-ESD-001 / PE-RET-001 | REVIEW unless mandatory FACT | -| PE-VIA-001 | via count + I_load (INFO, no tabella inventata) | +| PE-VIA-001 | via count + I datasheet (INFO, no tabella inventata); **skip** se manca I | | PE-THM-001 | P=I_load×drop, courtyard senza pour/via | | PE-THM-002 | Tj=Ta+P·θJA solo con P, θJA, area rame, conteggio via | | PE-BOM-010…014 | package / pinout / V / T / I quando BOM, PCB e datasheet hanno i numeri | diff --git a/periscope/src/frontend/content/changelog.md b/periscope/src/frontend/content/changelog.md index 9963030..09905e2 100644 --- a/periscope/src/frontend/content/changelog.md +++ b/periscope/src/frontend/content/changelog.md @@ -2,6 +2,13 @@ What's new in Periscope. +## 2.61.1 — 2026-09-21 — Skip trace-width vs current without datasheet I + +PE-PWR-001 (and PE-VIA-001) run only when the datasheet reports I_load / Imax / I_abs. Missing that number is a skip — not INFO INSUFFICIENT, not a typical USB 500 mA, not Iout_max-as-load. USB-C / Ethernet / PoE class certifiers still have no current/ampacity check. No via IPC table. No FEM. + +- [Fixed] PE-PWR-001 / PE-VIA-001 skip when datasheet current is absent. +- [Fixed] Imax / I_abs accepted for width; Iout_max is still not load. + ## 2.61.0 — 2026-09-21 — Interface class certifiers (USB-C, Ethernet, PoE) Dedicated per-interface certifiers (deterministic + AI explanation). Not generic SI. USB-C: CC1/CC2, Rp/Rd 5.1 kΩ, VBUS/GND, SuperSpeed only if the class is USB3. Ethernet: 10/100 vs GbE; magnetics required for GbE. PoE only with PoE evidence — a bare RJ45 is N/A, never invented PoE. Missing generation/class/ohms → INSUFFICIENT. DDR3 and via IPC ampacity are out of this slice. No FEM. diff --git a/periscope/src/frontend/package.json b/periscope/src/frontend/package.json index 2ea5a73..0473c8f 100644 --- a/periscope/src/frontend/package.json +++ b/periscope/src/frontend/package.json @@ -1,6 +1,6 @@ { "name": "periscope-web", - "version": "2.61.0", + "version": "2.61.1", "private": true, "scripts": { "sync-version": "node scripts/sync-version.mjs", diff --git a/tests/test_interface_class_check.py b/tests/test_interface_class_check.py index d5d1956..816350d 100644 --- a/tests/test_interface_class_check.py +++ b/tests/test_interface_class_check.py @@ -607,3 +607,16 @@ def test_does_not_use_layout_geometry(): assert "LayoutPad" not in src assert "LayoutSegment" not in src assert "LayoutZone" not in src + + +def test_usbc_vbus_does_not_invent_usb_500ma(): + """No typical USB current and no PE-PWR check without datasheet I.""" + findings = check_interface_classes(_usbc_device_graph()) + blob = " ".join( + " ".join(filter(None, (f.finding, f.why, f.facts, f.requirement, f.inference))) + for f in findings + ).lower() + assert "500 ma" not in blob + assert "500ma" not in blob + assert "0.5 a" not in blob + assert not any(f.rule_id in {"PE-PWR-001", "PE-VIA-001"} for f in findings) diff --git a/tests/test_pcb_review.py b/tests/test_pcb_review.py index 3d2abfc..20237b9 100644 --- a/tests/test_pcb_review.py +++ b/tests/test_pcb_review.py @@ -515,9 +515,44 @@ def test_power_trace_skips_without_i_load(): graph.components["U1"].specs.values["i_load_a"] = None # type: ignore[union-attr] graph.components["U1"].specs = None findings = check_pcb_power_traces(graph, cmap, layout) + assert findings == [] + + +def test_power_trace_skips_iout_max_as_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.clear() # type: ignore[union-attr] + graph.components["U1"].specs.values["iout_max_a"] = 10.0 # type: ignore[union-attr] + graph.components["U1"].specs.values["tj_max"] = 150.0 # type: ignore[union-attr] + findings = check_pcb_power_traces(graph, cmap, layout) + assert findings == [] + + +def test_power_trace_uses_imax_when_i_load_absent(): + from backend.periscopex.pcb_power_thermal import check_pcb_power_traces + + graph, cmap, layout = _ldo_graph_layout(i_load=10) + vals = graph.components["U1"].specs.values # type: ignore[union-attr] + vals.pop("i_load_a", None) + vals["i_max"] = 10.0 + findings = check_pcb_power_traces(graph, cmap, layout) assert findings assert findings[0].rule_id == "PE-PWR-001" - assert findings[0].evidence_status == "INSUFFICIENT" + assert findings[0].status == "ERROR" + + +def test_power_trace_uses_i_abs_when_i_load_absent(): + from backend.periscopex.pcb_power_thermal import check_pcb_power_traces + + graph, cmap, layout = _ldo_graph_layout(i_load=10) + vals = graph.components["U1"].specs.values # type: ignore[union-attr] + vals.pop("i_load_a", None) + vals["i_abs"] = 10.0 + findings = check_pcb_power_traces(graph, cmap, layout) + assert findings + assert findings[0].rule_id == "PE-PWR-001" + assert findings[0].status == "ERROR" def test_kelvin_sense_pin_on_shared_net(): diff --git a/tests/test_pcb_software_bugs.py b/tests/test_pcb_software_bugs.py index 31fd0e6..0decd82 100644 --- a/tests/test_pcb_software_bugs.py +++ b/tests/test_pcb_software_bugs.py @@ -307,7 +307,7 @@ def test_fb1_blm_is_bead_not_dcr_resistor(): assert getattr(dcr.specs, "value_ohms", None) is None -def test_via_current_insufficient_without_i_load(): +def test_via_current_skips_without_i_load(): from backend.periscopex.models import LayoutStackup cons = ComponentConstraints( @@ -339,11 +339,9 @@ def test_via_current_insufficient_without_i_load(): ], ) findings = check_pcb_via_current(graph, {"LDO1": cons}, layout) - assert findings and findings[0].rule_id == "PE-VIA-001" - assert findings[0].evidence_status == "INSUFFICIENT" + assert findings == [] pwr = check_pcb_power_traces(graph, {"LDO1": cons}, layout) - assert pwr and pwr[0].rule_id == "PE-PWR-001" - assert pwr[0].evidence_status == "INSUFFICIENT" + assert pwr == [] def test_thinking_reasoning_content_echo_still_present():