Ship Sprint 0 CAD foundations so findings, KiCad hierarchy, BOM match, eval, and optional PCB ingest have a stable contract.

Extend Finding with rule_id/net/pins, flatten multi-sheet .kicad_sch, flag MPN mismatches, score simple_project, and parse .kicad_pcb without running layout DRC.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-10 21:54:37 +02:00
co-authored by Cursor
parent d31c04ba86
commit e4fd6c3849
29 changed files with 1995 additions and 46 deletions
+73
View File
@@ -0,0 +1,73 @@
"""BOM vs schematic property matching.
Compares per-reference MPN/value from the schematic property table against
the uploaded BOM. Silent when the schematic map is empty (PADS/EDIF) so
we never invent orphans from a format that has no schematic properties.
"""
from __future__ import annotations
from backend.pinscopex.models import Finding
def _norm_mpn(value: object) -> str:
return " ".join(str(value or "").split()).upper()
def check_bom_schematic_match(
schematic: dict[str, dict],
bom: dict[str, dict],
) -> list[Finding]:
if not schematic:
return []
findings: list[Finding] = []
refs = sorted(set(schematic) | set(bom))
for ref in refs:
if ref.startswith("#"):
continue
sch = schematic.get(ref) or {}
bom_row = bom.get(ref) or {}
if ref not in schematic:
findings.append(Finding(
designator=ref,
mpn=str(bom_row.get("mpn") or ""),
aspect="bom_match",
source="bom_match",
status="WARNING",
finding=(
f"BOM lists {ref} but the schematic has no such reference."
),
why=(
"An extra BOM line that is not in the netlist will not be "
"validated against a datasheet and may indicate a stale BOM."
),
recommendation=f"Remove {ref} from the BOM or add it to the schematic.",
rule_id="PS-BOM-002",
pins=[],
))
continue
sch_mpn = _norm_mpn(sch.get("mpn"))
bom_mpn = _norm_mpn(bom_row.get("mpn"))
if sch_mpn and bom_mpn and sch_mpn != bom_mpn:
findings.append(Finding(
designator=ref,
mpn=str(sch.get("mpn") or ""),
aspect="bom_match",
source="bom_match",
status="ERROR",
finding=(
f"{ref} schematic MPN '{sch.get('mpn')}' does not match "
f"BOM MPN '{bom_row.get('mpn')}'."
),
why=(
"Datasheet review and library lookup follow one MPN. "
"A mismatch means the wrong die or a stale BOM row."
),
recommendation=(
f"Make {ref}'s BOM and schematic MPN identical, then re-run."
),
rule_id="PS-BOM-001",
pins=[ref],
))
return findings
+144
View File
@@ -0,0 +1,144 @@
"""Score a validation report against a golden key set.
Used by the simple_project eval harness: finding counts, % Unverified,
citation hit-rate among LLM quotes, precision/recall vs golden keys.
Deterministic checks without a quote are excluded from the citation
denominator so pin-mux/BOM noise cannot inflate the rate.
"""
from __future__ import annotations
from pathlib import Path
from pydantic import BaseModel
from backend.pinscopex.models import DesignGraph, Finding, ValidationReport
from backend.pinscopex.pin_mux_check import check_pin_mux_feasibility
from backend.pinscopex.led_current_check import check_led_current
from backend.pinscopex.passive_rail_check import (
check_i2c_pullups,
check_reset_pullups,
check_supply_decoupling,
)
from backend.pinscopex.bom_match_check import check_bom_schematic_match
class EvalScores(BaseModel):
finding_count: int
by_status: dict[str, int]
unverified_pct: float
citation_hit_rate: float | None
precision: float
recall: float
extra_keys: list[str]
missing_keys: list[str]
graph_ok: bool = True
graph_errors: list[str] = []
def finding_key(f: Finding) -> str:
if f.rule_id:
return f"{f.rule_id}|{f.designator}|{f.net or ''}"
return f"{f.source or 'review'}|{f.designator}|{f.net or f.finding}"
def _is_review(f: Finding) -> bool:
return not f.source or f.source == "review"
def citation_hit_rate(findings: list[Finding]) -> float | None:
quoted = [
f for f in findings
if _is_review(f) and (f.source_quote or "").strip()
]
if not quoted:
return None
hits = sum(1 for f in quoted if not (f.why or "").startswith("Unverified:"))
return hits / len(quoted)
def unverified_pct(findings: list[Finding]) -> float:
if not findings:
return 0.0
n = sum(1 for f in findings if (f.why or "").startswith("Unverified:"))
return 100.0 * n / len(findings)
def score_keys(produced: set[str], golden: set[str]) -> tuple[float, float, list[str], list[str]]:
extra = sorted(produced - golden)
missing = sorted(golden - produced)
precision = 1.0 if not produced else len(produced & golden) / len(produced)
recall = 1.0 if not golden else len(produced & golden) / len(golden)
return precision, recall, extra, missing
def run_deterministic_on_graph(graph: DesignGraph) -> list[Finding]:
cmap: dict = {}
out: list[Finding] = []
out.extend(check_pin_mux_feasibility(graph, cmap))
out.extend(check_led_current(graph))
out.extend(check_supply_decoupling(graph, cmap))
out.extend(check_i2c_pullups(graph, cmap))
out.extend(check_reset_pullups(graph, cmap))
out.extend(check_bom_schematic_match(graph.schematic_fields, graph.bom_fields))
return out
def score_report(
findings: list[Finding],
golden_keys: set[str],
*,
graph: DesignGraph | None = None,
golden_meta: dict | None = None,
) -> EvalScores:
keys = {finding_key(f) for f in findings}
precision, recall, extra, missing = score_keys(keys, golden_keys)
by_status: dict[str, int] = {"ERROR": 0, "WARNING": 0, "INFO": 0}
for f in findings:
by_status[f.status] = by_status.get(f.status, 0) + 1
graph_errors: list[str] = []
if graph is not None and golden_meta:
for ref in golden_meta.get("required_refs") or []:
if ref not in graph.components:
graph_errors.append(f"missing ref {ref}")
min_c = golden_meta.get("min_components")
if min_c and len(graph.components) < int(min_c):
graph_errors.append(
f"components {len(graph.components)} < {min_c}"
)
min_n = golden_meta.get("min_nets")
if min_n and len(graph.nets) < int(min_n):
graph_errors.append(f"nets {len(graph.nets)} < {min_n}")
return EvalScores(
finding_count=len(findings),
by_status=by_status,
unverified_pct=unverified_pct(findings),
citation_hit_rate=citation_hit_rate(findings),
precision=precision,
recall=recall,
extra_keys=extra,
missing_keys=missing,
graph_ok=not graph_errors,
graph_errors=graph_errors,
)
def eval_simple_project(
root: str | Path,
report: ValidationReport | None = None,
) -> EvalScores:
root = Path(root)
graph = DesignGraph.model_validate_json(
(root / "design_graph.json").read_text()
)
golden = {}
gpath = root / "eval_golden.json"
if gpath.is_file():
import json
golden = json.loads(gpath.read_text())
if report is not None:
findings = list(report.findings)
else:
findings = run_deterministic_on_graph(graph)
keys = set(golden.get("deterministic_keys") or [])
return score_report(findings, keys, graph=graph, golden_meta=golden)
+15 -1
View File
@@ -269,6 +269,11 @@ def build_graph(
# only tokenise correctly with the BOM's ref list as a lookup. EDIF # only tokenise correctly with the BOM's ref list as a lookup. EDIF
# netlists ignore known_refs (designators are unambiguous tokens). # netlists ignore known_refs (designators are unambiguous tokens).
bom = parse_bom(bom_path, reference_col=reference_col, mpn_col=mpn_col) bom = parse_bom(bom_path, reference_col=reference_col, mpn_col=mpn_col)
bom_fields = {
ref: {"mpn": entry.get("mpn"), "value": entry.get("value", "")}
for ref, entry in bom.items()
}
schematic_fields: dict[str, dict] = {}
parts, raw_nets, fmt = parse_netlist_any( parts, raw_nets, fmt = parse_netlist_any(
netlist_path, netlist_path,
known_refs=set(bom.keys()), known_refs=set(bom.keys()),
@@ -277,6 +282,10 @@ def build_graph(
if fmt.startswith("kicad"): if fmt.startswith("kicad"):
from backend.pinscopex.parsers_kicad import kicad_part_fields from backend.pinscopex.parsers_kicad import kicad_part_fields
for ref, extra in kicad_part_fields(netlist_path).items(): for ref, extra in kicad_part_fields(netlist_path).items():
schematic_fields[ref] = {
"mpn": extra.get("mpn"),
"value": extra.get("value", ""),
}
entry = bom.setdefault( entry = bom.setdefault(
ref, ref,
{"value": "", "footprint": "", "mpn": None, "lcsc": None, "datasheet_url": None}, {"value": "", "footprint": "", "mpn": None, "lcsc": None, "datasheet_url": None},
@@ -397,4 +406,9 @@ def build_graph(
pins=pin_connections, pins=pin_connections,
) )
return DesignGraph(components=components, nets=nets) return DesignGraph(
components=components,
nets=nets,
bom_fields=bom_fields,
schematic_fields=schematic_fields,
)
+51
View File
@@ -244,6 +244,12 @@ class DesignGraph(BaseModel):
""" """
components: dict[str, Component] = {} components: dict[str, Component] = {}
nets: dict[str, Net] = {} nets: dict[str, Net] = {}
# KiCad property table vs uploaded BOM (empty on PADS/EDIF).
bom_fields: dict[str, dict] = {}
schematic_fields: dict[str, dict] = {}
# KiCad property table vs uploaded BOM (empty on PADS/EDIF).
bom_fields: dict[str, dict] = {}
schematic_fields: dict[str, dict] = {}
# -- Traversal helpers -------------------------------------------------- # -- Traversal helpers --------------------------------------------------
@@ -330,6 +336,12 @@ class Finding(BaseModel):
recommendation: str = "" recommendation: str = ""
reference: str = "" reference: str = ""
source: str | None = None # None/"review" = LLM; "pin_mux_check"/"led_current_check"/"supply_decoupling_check"/… = deterministic source: str | None = None # None/"review" = LLM; "pin_mux_check"/"led_current_check"/"supply_decoupling_check"/… = deterministic
net: str | None = None # net name for CAD telemetry / SI filters
pins: list[str] = [] # e.g. ["U3.54"] for pan-and-zoom
rule_id: str | None = None # deterministic id, e.g. PS-MUX-001
cad_sheet: str | None = None # schematic sheet filename for plugin sync
cad_uuid: str | None = None # KiCad symbol/pin uuid
variant: str | None = None # DNP / ECO / assembly variant
class ValidationReport(BaseModel): class ValidationReport(BaseModel):
@@ -420,3 +432,42 @@ class ResolvedPassive(BaseModel):
power_rating: str | None = None power_rating: str | None = None
dielectric: str | None = None dielectric: str | None = None
raw_fields: dict[str, str] = {} raw_fields: dict[str, str] = {}
class LayoutPad(BaseModel):
number: str
x: float
y: float
net: str = ""
class LayoutFootprint(BaseModel):
reference: str
footprint: str = ""
x: float
y: float
layer: str = ""
pads: list[LayoutPad] = []
class LayoutSegment(BaseModel):
start: tuple[float, float]
end: tuple[float, float]
width: float = 0.0
layer: str = ""
net: str = ""
class LayoutVia(BaseModel):
x: float
y: float
net: str = ""
class LayoutGraph(BaseModel):
"""Parsed `.kicad_pcb` geometry. Optional; schema validation does not require it."""
nets: dict[str, int] = {}
footprints: dict[str, LayoutFootprint] = {}
segments: list[LayoutSegment] = []
vias: list[LayoutVia] = []
+178 -15
View File
@@ -1,8 +1,9 @@
"""KiCad netlist (XML / s-expression) and single-sheet ``.kicad_sch`` parser. """KiCad netlist (XML / s-expression) and ``.kicad_sch`` parser.
Yields the same ``(parts, nets)`` shape as PADS/EDIF so graph build is format-agnostic. Yields the same ``(parts, nets)`` shape as PADS/EDIF so graph build is format-agnostic.
``.kicad_sch`` uses embedded ``lib_symbols`` plus wires/labels; hierarchical ``.kicad_sch`` uses embedded ``lib_symbols`` plus wires/labels. Hierarchical
sheets in other files are not followed (export a netlist for those). ``(sheet …)`` entries are followed from the root file (path-jailed under the
project directory).
""" """
from __future__ import annotations from __future__ import annotations
@@ -10,6 +11,7 @@ from __future__ import annotations
import math import math
import re import re
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from typing import Any, Iterator from typing import Any, Iterator
@@ -307,7 +309,30 @@ class _DSU:
self.p[rb] = ra self.p[rb] = ra
def parse_kicad_sch(tree: Any) -> tuple[dict[str, str], dict[str, list[tuple[str, str]]], dict[str, dict]]: _KIND_RANK = {"unnamed": 0, "local": 1, "hier": 2, "global": 3}
@dataclass
class _SchSheet:
parts: dict[str, str]
nets: dict[str, list[tuple[str, str]]]
fields: dict[str, dict]
net_scope: dict[str, str]
sheetfiles: list[str] = field(default_factory=list)
def _sheetfiles(tree: Any) -> list[str]:
out: list[str] = []
for sheet in _kids(tree, "sheet"):
for p in _kids(sheet, "property"):
if len(p) >= 3 and str(p[1]) == "Sheetfile":
rel = str(p[2]).strip()
if rel:
out.append(rel)
return out
def _parse_kicad_sch_sheet(tree: Any) -> _SchSheet:
lib_pins: dict[str, dict[tuple[int, str], tuple[float, float]]] = {} lib_pins: dict[str, dict[tuple[int, str], tuple[float, float]]] = {}
for sym in _kids(_kid(tree, "lib_symbols") or [], "symbol"): for sym in _kids(_kid(tree, "lib_symbols") or [], "symbol"):
lid = str(sym[1]) if len(sym) > 1 else "" lid = str(sym[1]) if len(sym) > 1 else ""
@@ -318,7 +343,7 @@ def parse_kicad_sch(tree: Any) -> tuple[dict[str, str], dict[str, list[tuple[str
fields: dict[str, dict] = {} fields: dict[str, dict] = {}
pin_at: dict[tuple[str, str], tuple[int, int]] = {} pin_at: dict[tuple[str, str], tuple[int, int]] = {}
dsu = _DSU() dsu = _DSU()
labels: dict[tuple[int, int], str] = {} labels: dict[tuple[int, int], tuple[str, str]] = {}
power_pts: list[tuple[tuple[int, int], str]] = [] power_pts: list[tuple[tuple[int, int], str]] = []
def prop(sym: Any, key: str) -> str: def prop(sym: Any, key: str) -> str:
@@ -327,6 +352,13 @@ def parse_kicad_sch(tree: Any) -> tuple[dict[str, str], dict[str, list[tuple[str
return str(p[2]) return str(p[2])
return "" return ""
def set_label(pt: tuple[int, int], name: str, kind: str) -> None:
if not name:
return
prev = labels.get(pt)
if prev is None or _KIND_RANK[kind] >= _KIND_RANK[prev[1]]:
labels[pt] = (name, kind)
for sym in _kids(tree, "symbol"): for sym in _kids(tree, "symbol"):
lib_id = _val(sym, "lib_id") lib_id = _val(sym, "lib_id")
ix, iy, rot = _at(sym) ix, iy, rot = _at(sym)
@@ -392,13 +424,34 @@ def parse_kicad_sch(tree: Any) -> tuple[dict[str, str], dict[str, list[tuple[str
for a, b in zip(coords, coords[1:]): for a, b in zip(coords, coords[1:]):
dsu.union(a, b) dsu.union(a, b)
return return
if tag in {"label", "global_label", "hierarchical_label"}: if tag == "label":
name = str(node[1]) if len(node) > 1 else "" name = str(node[1]) if len(node) > 1 else ""
x, y, _ = _at(node) x, y, _ = _at(node)
pt = _snap(x, y) pt = _snap(x, y)
dsu.add(pt) dsu.add(pt)
if name: set_label(pt, name, "local")
labels[pt] = name return
if tag == "global_label":
name = str(node[1]) if len(node) > 1 else ""
x, y, _ = _at(node)
pt = _snap(x, y)
dsu.add(pt)
set_label(pt, name, "global")
return
if tag == "hierarchical_label":
name = str(node[1]) if len(node) > 1 else ""
x, y, _ = _at(node)
pt = _snap(x, y)
dsu.add(pt)
set_label(pt, name, "hier")
return
if tag == "sheet":
for pin in _kids(node, "pin"):
name = str(pin[1]) if len(pin) > 1 else ""
x, y, _ = _at(pin)
pt = _snap(x, y)
dsu.add(pt)
set_label(pt, name, "hier")
return return
if tag == "junction": if tag == "junction":
x, y, _ = _at(node) x, y, _ = _at(node)
@@ -410,33 +463,143 @@ def parse_kicad_sch(tree: Any) -> tuple[dict[str, str], dict[str, list[tuple[str
collect_pts(tree) collect_pts(tree)
for pt, name in labels.items(): for pt in labels:
dsu.add(pt) dsu.add(pt)
for pt, _name in power_pts: for pt, _name in power_pts:
dsu.add(pt) dsu.add(pt)
# Merge labels/power onto coinciding pin/wire points (already same snap keys).
nets: dict[str, list[tuple[str, str]]] = {}
root_name: dict[tuple[int, int], str] = {} root_name: dict[tuple[int, int], str] = {}
for pt, name in labels.items(): root_kind: dict[tuple[int, int], str] = {}
root_name[dsu.find(pt)] = name for pt, (name, kind) in labels.items():
r = dsu.find(pt)
prev = root_kind.get(r, "unnamed")
if _KIND_RANK[kind] >= _KIND_RANK[prev]:
root_name[r] = name
root_kind[r] = kind
for pt, name in power_pts: for pt, name in power_pts:
root_name.setdefault(dsu.find(pt), name) r = dsu.find(pt)
prev = root_kind.get(r, "unnamed")
if _KIND_RANK["global"] >= _KIND_RANK[prev]:
root_name[r] = name
root_kind[r] = "global"
grouped: dict[tuple[int, int], list[tuple[str, str]]] = {} grouped: dict[tuple[int, int], list[tuple[str, str]]] = {}
for (ref, pin), pt in pin_at.items(): for (ref, pin), pt in pin_at.items():
grouped.setdefault(dsu.find(pt), []).append((ref, pin)) grouped.setdefault(dsu.find(pt), []).append((ref, pin))
nets: dict[str, list[tuple[str, str]]] = {}
net_scope: dict[str, str] = {}
used_names: set[str] = set() used_names: set[str] = set()
for root, pins in grouped.items(): for root, pins in grouped.items():
name = root_name.get(root) name = root_name.get(root)
kind = root_kind.get(root, "unnamed")
if not name: if not name:
ref0, pin0 = pins[0] ref0, pin0 = pins[0]
name = f"Net-({ref0}-Pad{pin0})" name = f"Net-({ref0}-Pad{pin0})"
kind = "unnamed"
while name in used_names: while name in used_names:
name = name + "_" name = name + "_"
used_names.add(name) used_names.add(name)
nets[name] = pins nets[name] = pins
net_scope[name] = kind
return _SchSheet(
parts=parts,
nets=nets,
fields=fields,
net_scope=net_scope,
sheetfiles=_sheetfiles(tree),
)
def parse_kicad_sch(tree: Any) -> tuple[dict[str, str], dict[str, list[tuple[str, str]]], dict[str, dict]]:
sheet = _parse_kicad_sch_sheet(tree)
return sheet.parts, sheet.nets, sheet.fields
def _uniq_pins(pins: list[tuple[str, str]]) -> list[tuple[str, str]]:
seen: set[tuple[str, str]] = set()
out: list[tuple[str, str]] = []
for p in pins:
if p not in seen:
seen.add(p)
out.append(p)
return out
def _safe_sheetfile(parent: Path, rel: str, project_root: Path) -> Path:
rel_norm = rel.replace("\\", "/").strip()
if not rel_norm or rel_norm.startswith("/") or ".." in Path(rel_norm).parts:
raise ValueError(f"Sheetfile path rejected: {rel}")
child = (parent.parent / rel_norm).resolve()
root = project_root.resolve()
try:
child.relative_to(root)
except ValueError:
raise ValueError(f"Sheetfile path rejected: {rel}") from None
return child
def parse_kicad_sch_project(
root_path: str | Path,
) -> tuple[dict[str, str], dict[str, list[tuple[str, str]]], dict[str, dict]]:
root = Path(root_path).resolve()
project_root = root.parent
seen: set[Path] = set()
loaded: list[tuple[Path, _SchSheet]] = []
def visit(path: Path) -> None:
path = path.resolve()
if path in seen:
raise ValueError(f"Cyclic sheet include: {path.name}")
if not path.is_file():
raise ValueError(f"Missing sheet file: {path.name}")
seen.add(path)
text = path.read_text(encoding="utf-8", errors="replace")
tree = _parse_sexp(text)
if _tag(tree) != "kicad_sch":
raise ValueError(f"Expected kicad_sch in {path.name}, got {_tag(tree)!r}")
sheet = _parse_kicad_sch_sheet(tree)
loaded.append((path, sheet))
for rel in sheet.sheetfiles:
child = _safe_sheetfile(path, rel, project_root)
visit(child)
visit(root)
parts: dict[str, str] = {}
fields: dict[str, dict] = {}
global_nets: dict[str, list[tuple[str, str]]] = {}
hier_nets: dict[str, list[tuple[str, str]]] = {}
local_nets: dict[str, list[tuple[str, str]]] = {}
multi = len(loaded) > 1
for path, sheet in loaded:
for ref, fp in sheet.parts.items():
if ref in parts:
raise ValueError(f"Duplicate reference {ref} in {path.name}")
parts[ref] = fp
fields[ref] = sheet.fields.get(ref, {})
for name, pins in sheet.nets.items():
scope = sheet.net_scope.get(name, "unnamed")
if scope == "global":
global_nets[name] = _uniq_pins(global_nets.get(name, []) + pins)
elif scope == "hier":
hier_nets[name] = _uniq_pins(hier_nets.get(name, []) + pins)
else:
out_name = f"{path.stem}/{name}" if multi else name
local_nets[out_name] = _uniq_pins(local_nets.get(out_name, []) + pins)
nets: dict[str, list[tuple[str, str]]] = {}
for name, pins in global_nets.items():
nets[name] = pins
for name, pins in hier_nets.items():
nets[name] = _uniq_pins(nets.get(name, []) + pins)
for name, pins in local_nets.items():
out = name
while out in nets:
out = out + "_"
nets[out] = pins
return parts, nets, fields return parts, nets, fields
@@ -461,7 +624,7 @@ def parse_kicad(
tree = _parse_sexp(text) tree = _parse_sexp(text)
tag = _tag(tree) tag = _tag(tree)
if tag == "kicad_sch": if tag == "kicad_sch":
parts, nets, fields = parse_kicad_sch(tree) parts, nets, fields = parse_kicad_sch_project(p)
elif tag == "export": elif tag == "export":
parts, nets, fields = parse_kicad_sexp_netlist(tree) parts, nets, fields = parse_kicad_sexp_netlist(tree)
else: else:
+131
View File
@@ -0,0 +1,131 @@
"""KiCad `.kicad_pcb` ingest — footprints, pads, nets, segments, vias.
No SI/DRC. Schema validation stays complete without this file.
"""
from __future__ import annotations
from pathlib import Path
from backend.pinscopex.models import (
LayoutFootprint,
LayoutGraph,
LayoutPad,
LayoutSegment,
LayoutVia,
)
from backend.pinscopex.parsers_kicad import (
_at,
_fnum,
_kid,
_kids,
_parse_sexp,
_rotate,
_tag,
_val,
)
def _xy(node: object, name: str) -> tuple[float, float]:
k = _kid(node, name)
if not k or len(k) < 3:
return 0.0, 0.0
return _fnum(k[1]), _fnum(k[2])
def _prop(node: object, key: str) -> str:
for p in _kids(node, "property"):
if len(p) >= 3 and str(p[1]) == key:
return str(p[2])
return ""
def _pad_net(pad: object) -> str:
n = _kid(pad, "net")
if n and len(n) >= 3:
return str(n[2])
return ""
def parse_kicad_pcb(path: str | Path) -> LayoutGraph:
p = Path(path)
tree = _parse_sexp(p.read_text(encoding="utf-8", errors="replace"))
if _tag(tree) != "kicad_pcb":
raise ValueError(f"Expected kicad_pcb, got {_tag(tree)!r}")
nets: dict[str, int] = {}
footprints: dict[str, LayoutFootprint] = {}
segments: list[LayoutSegment] = []
vias: list[LayoutVia] = []
for node in tree[1:]:
if not isinstance(node, list) or not node:
continue
tag = _tag(node)
if tag == "net" and len(node) >= 3 and not any(isinstance(x, list) and x and x[0] == "node" for x in node[1:]):
try:
code = int(_fnum(node[1]))
except (TypeError, ValueError):
continue
name = str(node[2])
if name:
nets[name] = code
continue
if tag in {"footprint", "module"}:
fp_name = str(node[1]) if len(node) > 1 and not isinstance(node[1], list) else ""
fx, fy, frot = _at(node)
layer = _val(node, "layer")
ref = _prop(node, "Reference")
if not ref or ref.startswith("#"):
continue
pads: list[LayoutPad] = []
for pad in _kids(node, "pad"):
num = str(pad[1]) if len(pad) > 1 else ""
if not num:
continue
px, py, _ = _at(pad)
rx, ry = _rotate(px, py, frot)
pads.append(LayoutPad(
number=num,
x=fx + rx,
y=fy + ry,
net=_pad_net(pad),
))
footprints[ref] = LayoutFootprint(
reference=ref,
footprint=fp_name,
x=fx,
y=fy,
layer=layer,
pads=pads,
)
continue
if tag == "segment":
net_el = _kid(node, "net")
net_name = ""
if net_el and len(net_el) >= 2:
code = int(_fnum(net_el[1]))
net_name = next((n for n, c in nets.items() if c == code), str(code))
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,
))
continue
if tag == "via":
net_el = _kid(node, "net")
net_name = ""
if net_el and len(net_el) >= 2:
code = int(_fnum(net_el[1]))
net_name = next((n for n, c in nets.items() if c == code), str(code))
vx, vy, _ = _at(node)
vias.append(LayoutVia(x=vx, y=vy, net=net_name))
return LayoutGraph(
nets=nets,
footprints=footprints,
segments=segments,
vias=vias,
)
+9
View File
@@ -84,6 +84,9 @@ def check_supply_decoupling(
f"near {ref}." f"near {ref}."
), ),
reference="netlist topology", reference="netlist topology",
net=net_name,
pins=[f"{ref}.{pin_num}"],
rule_id="PS-DEC-001",
)) ))
return findings return findings
@@ -133,6 +136,9 @@ def check_i2c_pullups(
f"to the I2C I/O rail." f"to the I2C I/O rail."
), ),
reference="netlist topology", reference="netlist topology",
net=net_name,
pins=[f"{ref}.{pin_num}"],
rule_id="PS-I2C-001",
)) ))
return findings return findings
@@ -184,6 +190,9 @@ def check_reset_pullups(
f"from a reset supervisor / GPIO." f"from a reset supervisor / GPIO."
), ),
reference="netlist topology", reference="netlist topology",
net=net_name,
pins=[f"{ref}.{pin_num}"],
rule_id="PS-RST-001",
)) ))
return findings return findings
+3
View File
@@ -169,4 +169,7 @@ def _feasibility_finding(
), ),
recommendation=rec, recommendation=rec,
reference=f"{mpn or ref} alternate-function table", reference=f"{mpn or ref} alternate-function table",
net=net_name,
pins=[f"{ref}.{pin_num}"],
rule_id="PS-MUX-001",
) )
+31
View File
@@ -520,6 +520,37 @@ async def upload_netlist(project_id: str, file: UploadFile, request: Request):
} }
@router.post("/projects/{project_id}/upload/pcb")
async def upload_pcb(project_id: str, file: UploadFile, request: Request):
storage = get_storage(request)
result = proj_svc.resolve_project_access(storage, get_user_id(request), project_id)
if not result:
raise HTTPException(404, "Project not found")
user_id = result[0]
data = await file.read()
if len(data) > MAX_UPLOAD_BYTES:
raise HTTPException(413, f"File too large (max {MAX_UPLOAD_BYTES // 1024 // 1024} MB)")
import tempfile, os
from backend.pinscopex.parsers_kicad_pcb import parse_kicad_pcb
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".kicad_pcb")
try:
tmp.write(data)
tmp.close()
layout = parse_kicad_pcb(tmp.name)
except Exception as e:
raise HTTPException(400, f"Invalid KiCad PCB: {e}")
finally:
os.unlink(tmp.name)
key = proj_svc.save_pcb(storage, user_id, project_id, data)
return {
"path": key,
"footprints": len(layout.footprints),
"nets": len(layout.nets),
"segments": len(layout.segments),
}
def _build_designator_pins( def _build_designator_pins(
parts: dict[str, str], parts: dict[str, str],
nets: dict[str, list[tuple[str, str]]], nets: dict[str, list[tuple[str, str]]],
+8 -15
View File
@@ -247,23 +247,16 @@ def _build_deduped(
new_why = "Unverified: " + new_why new_why = "Unverified: " + new_why
try: try:
result.append(Finding( result.append(canon.model_copy(update={
finding_id=canon.finding_id, "finding": str(group.get("finding") or canon.finding),
designator=canon.designator, "why": new_why,
mpn=canon.mpn, "source_page": group.get("source_page", canon.source_page),
aspect=canon.aspect, "status": final_status,
finding=str(group.get("finding") or canon.finding), "recommendation": str(
why=new_why,
source_page=group.get("source_page", canon.source_page),
source_quote=canon.source_quote,
source_designator=canon.source_designator,
status=final_status,
recommendation=str(
group.get("recommendation") or canon.recommendation group.get("recommendation") or canon.recommendation
), ),
reference=str(group.get("reference") or canon.reference), "reference": str(group.get("reference") or canon.reference),
source=canon.source, }))
))
except Exception: except Exception:
log.exception("dedupe: failed to build merged Finding") log.exception("dedupe: failed to build merged Finding")
return None return None
+9 -14
View File
@@ -394,20 +394,15 @@ def _build_normalized(
new_why = "Unverified: " + new_why new_why = "Unverified: " + new_why
try: try:
result.append(Finding( result.append(canon.model_copy(update={
finding_id=canon.finding_id, "finding": str(entry.get("finding") or canon.finding),
designator=canon.designator, "why": new_why,
mpn=canon.mpn, "source_page": entry.get("source_page", canon.source_page),
aspect=canon.aspect, "source_quote": str(entry.get("source_quote") or canon.source_quote),
finding=str(entry.get("finding") or canon.finding), "status": final_status,
why=new_why, "recommendation": str(entry.get("recommendation") or canon.recommendation),
source_page=entry.get("source_page", canon.source_page), "reference": str(entry.get("reference") or canon.reference),
source_quote=str(entry.get("source_quote") or canon.source_quote), }))
source_designator=canon.source_designator,
status=final_status,
recommendation=str(entry.get("recommendation") or canon.recommendation),
reference=str(entry.get("reference") or canon.reference),
))
except Exception: except Exception:
log.exception("normalize: failed to build merged Finding") log.exception("normalize: failed to build merged Finding")
return None return None
+21
View File
@@ -1496,6 +1496,25 @@ async def _stage_passive_extraction(ctx: PipelineContext) -> None:
{"stage": "passive_extraction", "status": "complete"}) {"stage": "passive_extraction", "status": "complete"})
def _write_layout_graph(ws: PipelineWorkspace, project_id: str) -> None:
"""Parse optional `.kicad_pcb`. Fail-soft — schema validation must still run."""
pcb = ws.local_path("uploads/pcb.kicad_pcb")
if not pcb.is_file():
return
try:
from backend.pinscopex.parsers_kicad_pcb import parse_kicad_pcb
layout = parse_kicad_pcb(pcb)
out = ws.local_path("layout_graph.json")
out.write_text(layout.model_dump_json(indent=2) + "\n")
logger.info(
"layout_graph: %s footprints, %s nets for %s",
len(layout.footprints), len(layout.nets), project_id,
)
except Exception:
logger.exception("kicad_pcb parse failed — continuing without layout")
async def _stage_graph_build(ctx: PipelineContext) -> None: async def _stage_graph_build(ctx: PipelineContext) -> None:
"""Stage 4 — Build the design graph from netlist, BOM, and extracted data.""" """Stage 4 — Build the design graph from netlist, BOM, and extracted data."""
broker.publish(ctx.project_id, "step_update", broker.publish(ctx.project_id, "step_update",
@@ -1525,6 +1544,7 @@ async def _stage_graph_build(ctx: PipelineContext) -> None:
graph_path = ctx.ws.local_path("design_graph.json") graph_path = ctx.ws.local_path("design_graph.json")
graph_path.write_text(ctx.graph.model_dump_json(indent=2) + "\n") graph_path.write_text(ctx.graph.model_dump_json(indent=2) + "\n")
_write_layout_graph(ctx.ws, ctx.project_id)
broker.publish(ctx.project_id, "step_update", broker.publish(ctx.project_id, "step_update",
{"stage": "graph_build", "status": "complete", {"stage": "graph_build", "status": "complete",
@@ -2088,6 +2108,7 @@ async def run_regen_pipeline(
graph_path = ws.local_path("design_graph.json") graph_path = ws.local_path("design_graph.json")
graph_path.write_text(graph.model_dump_json(indent=2) + "\n") graph_path.write_text(graph.model_dump_json(indent=2) + "\n")
_write_layout_graph(ws, project_id)
broker.publish(project_id, "step_update", broker.publish(project_id, "step_update",
{"stage": "graph_build", "status": "complete", {"stage": "graph_build", "status": "complete",
+11
View File
@@ -5,6 +5,7 @@ Each project lives at users/{user_id}/projects/{id}/ with:
uploads/bom.csv — uploaded BOM uploads/bom.csv — uploaded BOM
uploads/netlist.asc — uploaded netlist uploads/netlist.asc — uploaded netlist
uploads/datasheets/*.pdf — uploaded datasheets uploads/datasheets/*.pdf — uploaded datasheets
uploads/pcb.kicad_pcb — optional KiCad board (layout checks)
extracted/ — IC extraction output extracted/ — IC extraction output
patterns/ — passive patterns patterns/ — passive patterns
models/ — cached component specs models/ — cached component specs
@@ -82,6 +83,7 @@ class ProjectMeta(BaseModel):
updated: str = "" updated: str = ""
has_bom: bool = False has_bom: bool = False
has_netlist: bool = False has_netlist: bool = False
has_pcb: bool = False
# "pads" | "edif" | None — None for legacy projects (pre-EDIF-support). # "pads" | "edif" | None — None for legacy projects (pre-EDIF-support).
# Legacy reads fall back to looking for netlist.asc on disk. # Legacy reads fall back to looking for netlist.asc on disk.
netlist_format: str | None = None netlist_format: str | None = None
@@ -658,6 +660,15 @@ def save_netlist(
return key return key
def save_pcb(
storage: StorageBackend, user_id: str, project_id: str, data: bytes
) -> str:
key = f"{_project_prefix(user_id, project_id)}/uploads/pcb.kicad_pcb"
storage.write_bytes(key, data)
update_project(storage, user_id, project_id, has_pcb=True)
return key
def save_datasheet( def save_datasheet(
storage: StorageBackend, user_id: str, project_id: str, mpn: str, data: bytes storage: StorageBackend, user_id: str, project_id: str, mpn: str, data: bytes
) -> str: ) -> str:
+4
View File
@@ -49,6 +49,7 @@ from backend.pinscopex.passive_rail_check import (
check_reset_pullups, check_reset_pullups,
check_supply_decoupling, check_supply_decoupling,
) )
from backend.pinscopex.bom_match_check import check_bom_schematic_match
TRACE_VERSION = 1 TRACE_VERSION = 1
@@ -70,6 +71,9 @@ def _run_deterministic_checks(
("supply_decoupling_check", lambda: check_supply_decoupling(graph, constraints_map)), ("supply_decoupling_check", lambda: check_supply_decoupling(graph, constraints_map)),
("i2c_pullup_check", lambda: check_i2c_pullups(graph, constraints_map)), ("i2c_pullup_check", lambda: check_i2c_pullups(graph, constraints_map)),
("reset_pullup_check", lambda: check_reset_pullups(graph, constraints_map)), ("reset_pullup_check", lambda: check_reset_pullups(graph, constraints_map)),
("bom_match_check", lambda: check_bom_schematic_match(
graph.schematic_fields, graph.bom_fields,
)),
): ):
try: try:
out.extend(fn()) out.extend(fn())
+510
View File
@@ -0,0 +1,510 @@
# Piano di implementazione — Pinscope
Documento di lavoro **prima dello sviluppo**. La lista dellutente è il minimo; sotto c’è anche ciò che serve perché quella lista non resti un insieme di moduli scollegati.
**Questo piano copre due prodotti.** Pinscope originale resta il primo. Layout/plugin/placement mm sono il secondo. Non mescolare i changelog né vendere il secondo come “un po di Pinscope in più”.
Stato del codice di riferimento: branch `cursor/deepseek-71c5` (post DeepSeek V4.1, costi USD, replace BOM/netlist, parser KiCad, fingerprint review).
---
## 0. Due prodotti (stesso repo, due promesse)
| | **Pinscope** (oggi + wave AB, C schema, F/H leggere) | **Pinscope Layout** (wave D parziale, G, C4+G2, plugin pcbnew) |
| --- | --- | --- |
| Promessa | Lo schema rispetta il datasheet | Il rame rispetta datasheet + geometria |
| File | BOM, netlist, `.kicad_sch` gerarchico | + `.kicad_pcb` |
| Output | Finding su pin/net, derating, power tree | Distanze mm, 3W, creepage, skew, via EP |
| Utente | Chi chiude lo schema | Chi sbroglia |
Farli nello stesso codebase (`pinscopex` + `LayoutGraph`) è ragionevole. Farli **nella stessa run obbligatoria** no: senza PCB il progetto deve restare un Pinscope completo, non “incompleto perché manca il gerber”.
Nome in UI: tab **Layout** o prodotto “Layout checks” gated dal file `.kicad_pcb`. Il report schema non deve riempirsi di `PS-PLC` se il PCB non c’è.
Non serve un fork oggi. Serve disciplina: ogni PR dichiara se è Core o Layout.
---
## 0. Contratto di prodotto (non negoziabile)
Pinscope oggi è un **validatore di schema**: BOM + netlist → grafo bipartito → check deterministici + review LLM con citazione datasheet. Non legge il PCB.
Molti punti della lista (larghezza traccia, 3W, creepage, CPW clearance, length matching) **non esistono senza geometria**. Il piano li tiene, ma li mette **dopo** un ingest layout. Se li si forza sullo schema si producono finding inventati.
### Ingresso ufficiale: progetto KiCad (non EasyEDA)
Coppia da chiedere allutente (e da salvare insieme):
| File | Ruolo |
| --- | --- |
| **Root `.kicad_sch`** (+ fogli figli) | Connettività, ref, uuid simbolo/pin, MPN nei property |
| **`.kicad_pcb`** | Rame, net, coppie, courtyard, stackup |
BOM CSV resta utile se i property MPN nello schema sono vuoti; se lo schema è completo, la BOM è opzionale.
**EasyEDA è fuori scope.** Niente plugin, niente parser JSON EasyEDA, niente DRC sulleditor cloud. Chi arriva da EasyEDA resta sul percorso già documentato (export PADS), senza lavoro nuovo.
**Gerber** non è lingresso primario: con `.kicad_pcb` i net ci sono già. I Gerber restano un eventuale piano B, non lo sprint 1 del layout.
### Schema gerarchico (più file) — attenzione
Oggi `parse_kicad_sch` legge **un solo foglio**. I `(sheet …)` che puntano ad altri `.kicad_sch` **non vengono seguiti**. Lerrore attuale chiede di esportare la netlist.
Da fare (Wave A1), in ordine:
1. Upload: cartella progetto o zip, **oppure** il `.kicad_pro` + root schematic. Non un solo foglio figlio.
2. Dal root: camminare ogni `(sheet (property "Sheetfile" "power.kicad_sch") …)` (KiCad 610: `Sheetfile` / path relativo al foglio padre).
3. Path traversal: solo file sotto la root del progetto; rifiutare `../`.
4. Unire la connettività:
- **local label** restano nel foglio;
- **hierarchical_label** sul figlio ↔ **sheet pin** sul padre (stesso nome);
- **global_label** e power symbol (`#PWR`) sono globali su tutto il progetto.
5. Path gerarchico KiCad (`/power/U1` vs `U1`): normalizzare i **Reference** come li vede il PCB (di solito già unici; se duplicati, è un errore dello schema).
6. Uuid: `(uuid …)` su symbol e pin, più `sheet` uuid, per il plugin (pan-and-zoom sul foglio giusto).
7. Fixture di test: root + 2 figli (alimentazione + analog), label gerarchiche su un net, power flag GND condiviso. Deve coincidere col netlist XML esportato da KiCad 9.
**Done when:** progetto a 3 fogli senza export netlist produce gli stessi `(ref, pin, net)` del file `*.xml` di KiCad.
`.kicad_pcb` è un file solo (il board non è gerarchico come lo schema). I net name nel PCB devono matchare i net risolti dallo schema dopo il flatten gerarchico.
Regole:
1. Ogni nuovo check è una funzione pura in `backend/pinscopex/` che legge `DesignGraph` (+ opzionale layout). Niente SDK LLM dentro `pinscopex/`.
2. I finding usano lo stesso schema (`Finding` in `models.py` / `frontend/src/lib/types.ts`). Campo `source` già distingue check automatici vs review.
3. Finding normalization resta **downgrade-only**.
4. Libreria condivisa: MPN exact-match. Niente fuzzy sul die.
5. Plugin CAD **consumano** il report; non duplicano la pipeline.
### Estensione schema finding (fare per prima, una volta)
Oggi: `designator`, `mpn`, `aspect`, `finding`, `why`, `status`, `source_page`, `recommendation`, `source`.
Aggiungere (backward compatible):
| Campo | Serve a |
| --- | --- |
| `net` | Telemetry CAD, filtri, SI |
| `pins[]` | Pan-and-zoom su U1.4 |
| `rule_id` | Plugin DRC (`PS-DEC-001`) |
| `cad_sheet` / `cad_uuid` | Sync plugin KiCad |
| `variant` | DNP / ECO |
| `severity_calibrated` | già implicito; non alzare in post |
Passi:
1. Estendere `Finding` in `backend/pinscopex/models.py` e `frontend/src/lib/types.ts`.
2. Aggiornare `assign_finding_ids`, export Excel, report UI (campi opzionali nascosti se null).
3. Test su `simple_project/` che i check esistenti ancora serializzano.
**Done when:** un finding di decoupling ha `rule_id` + `pins` e il report non rompe i finding LLM vecchi.
---
## Mappa lista ↔ codice attuale
| Blocco utente | Già c’è | Buco |
| --- | --- | --- |
| 1 Plugin / telemetry / BOM match | Parser KiCad XML/sexp/`.kicad_sch` **singolo foglio**; wizard BOM | Hierarchie multi-file; uuid; plugin; **EasyEDA fuori scope** |
| 2 Datasheet / errata / OCR blocchi | Pintable+abs-max, PDF text+vision, excerpt per topic, quote_verify | Nessun RAG vendor; niente errata; vision non estrae clamp/ESD dal block diagram in schema strutturato |
| 3 Impedenze / stackup | — | Motore assente; niente PCB |
| 4 Filtri | Review LLM può parlarne | Nessun matcher topologico, niente \(f_c\) |
| 5 Capacità PI | Decoupling check **sulla net**; derating V | No DC bias; ESR/ESL; Cin/Cout; **niente distanza cap↔pin sul PCB** |
| 6 Elettrico | Pin mux; I2C/reset pull-up; power tree UI; LED current | Sequencing assente; pull-up non dimensionati (solo presenza); drop IR assente |
| 7 RF | Review “ruolo parte” / bias-T | Nessun matching 50 Ω, niente clearance |
| 8 HV / isolation | — | Serve layout + profilo normativo |
| 9 Termico | Excerpt topic thermal | Nessun \(T_j\), niente P=I²R resistori |
| 10 SI / DNP | Fingerprint skip IC; skipped/not_reviewed | DNP non è un modello; niente length/crosstalk |
| 11 Lifecycle | DigiKey/LCSC/Mouser per datasheet e passivi | Nessun EOL/NRND/RoHS in report |
| 13 Placement da datasheet | Review può citare “place close to pin”; vision su application pages | Nessuna misura mm sul `.kicad_pcb`; nessuna struttura `layout_rules` estratta |
---
## Extra obbligatori (non nella lista, ma bloccanti)
Senza questi i moduli 112 non si misurano e i plugin mentono.
**E0. Eval harness.** Golden findings su `simple_project/` + 12 board interni. Precision/recall, % Unverified, citation hit-rate (`quote_verify`). Ogni check nuovo deve aggiungere fixture.
**E1. KiCad gerarchico GA.** Obbligatorio. Vedi “Schema gerarchico” sopra. Senza flatten dei fogli il plugin e il `.kicad_pcb` non allineano i net.
**E2. Protocollo plugin KiCad** (`pinscope-cad-bridge` JSON). Un file per progetto:
```json
{
"version": 1,
"project_id": "...",
"findings": [
{
"rule_id": "PS-MUX-001",
"ref": "U3",
"pins": ["12"],
"sheet": "...",
"uuid": "...",
"severity": "error",
"message": "...",
"url": "https://pinscope.../report?finding=U3-001"
}
]
}
```
Un solo adapter: **KiCad 9/10**. Niente secondo plugin EasyEDA.
**E3. Smoke DeepSeek V4.1** su `simple_project` (vision + finding count vs run precedente). Già in coda P0.
---
## Wave A — Fondamenta CAD e finding (sblocca 1 e 6)
Obiettivo: schema KiCad fidato, finding indirizzabili, sync unidirezionale.
### A1. KiCad nativo “GA” (schema gerarchico)
Passi: quelli in “Schema gerarchico (più file)” + file-guide riscritta (niente “esporta PADS da KiCad” come percorso principale) + `cad_index.json` (ref → uuid → sheetfile).
**Done when:** fixture a più fogli = netlist XML KiCad; upload zip/progetto senza chiedere lexport.
### A3. Bridge KiCad (punto 1)
Passi:
1. Pacchetto `plugins/kicad/` (action plugin Python, KiCad 9/10).
2. `pinscope-findings.json` dal report (E2).
3. Marcatori / focus `uuid` su **eeschema** (foglio figlio corretto) e, per finding layout, su **pcbnew**.
4. Pan-and-zoom: `FocusOnItem` / select symbol; se lAPI 10 differisce, adapter sottile.
**Done when:** clic su finding centra U3 sul foglio `power.kicad_sch`, non sul root vuoto.
EasyEDA: **non si fa.**
### A2. BOM-to-schematic matching (punto 1)
Non è uno script a parte: è il grafo.
Passi:
1. Da campi KiCad (`MPN`, `mpn`, `PN`, `lcsc`) già letti in `parsers_kicad.py` / `graph.py`.
2. Tabella conflitti: ref in schema senza MPN, MPN in BOM senza ref, mismatch Value.
3. Finding `source=bom_match` con `rule_id=PS-BOM-001`.
4. UI wizard: riga rossa nel matching, non solo colonne.
**Done when:** U1 in schema e U1 in BOM con MPN diversi → ERROR citabile.
---
## Wave B — Check deterministici schema (sblocca 4, 5, 6, 9, 10 DNP)
Obiettivo: meno LLM, più numeri. Ogni item = modulo `pinscopex` + test su grafo sintetico + riga in eval.
### B1. Power tree & drop (6)
Passi:
1. Riuse UI power tree esistente.
2. Per ogni IC: somma IQ + load stimato da specs se c’è; confronta con `Iout_max` LDO/Buck se estratto.
3. IR drop **solo se** esiste Rseries esplicito (shunt/ferrite) — niente stima di pista.
4. Finding `PS-PWR-001` margin fail.
### B2. Sequencing (6)
Passi:
1. Estrarre da datasheet (skill specs o tabella) `power_sequence` se presente; altrimenti skip.
2. Sul grafo: enable pin, RC delay, PG (power good) concatenati.
3. WARNING se sequenza dichiarata e PG non collega lenable del rail successivo; niente invenzione di millisecondi.
### B3. Pull-up dimensionamento (6)
Passi:
1. Estendere `check_i2c_pullups`: presenza **e** valore vs Vrail e Cf (formula NXP UM10204, bound largo).
2. Reset: stesso, più pull-down vietato se datasheet dice active-low con pull-up interno assente.
### B4. Mux (6)
Già `pin_mux_check.py`. Passi residui: coprire SPI/UART con gli stessi token; test su MSPM0 del `simple_project`.
### B5. Decoupling audit (5)
Già `check_supply_decoupling`. Passi:
1. Distinguere Cin LDO vs Cout (pin IN/OUT dal pintable, non solo VDD).
2. Contare valore se specs passive ci sono (100 nF vs 10 µF) come WARNING non ERROR se il vendor non è esplicito.
### B6. Derating DC bias (5)
Passi:
1. Tabella empirica C0G/X7R/X5R (loss % vs V/Vrated) in `derating.py` — etichettare “stima”, non misura.
2. UI derating: colonna C_eff.
3. Non spacciare per curva Murata del lotto.
### B7. ESR/ESL parallelo (5)
Passi:
1. Modello grezzo: C + ESL package (0402/0603 lookup) + ESR da specs se c’è.
2. Z(f) somma parallelo 10µF+100nF; confronta con f_sw buck se nota.
3. Senza f_sw: INFO “copertura HF dipende da 100nF vicino al pin” senza fingere un target Ω.
### B8. Filtri (4)
Passi:
1. Matcher topologico sul grafo: RC serie-shunt, LC, ferrite+C, π (C-L-C), T (L-C-L).
2. \(f_c\) per RC (`1/2πRC`) e LC (`1/2π√LC`); Pi/T solo se L e C noti.
3. Confronto con `adc_sample_rate` / `data_rate` **solo se** in specs IC.
4. Insertion loss: WARNING se ferrite DCR alta su rail ADC analog (soglia da datasheet o skip).
### B9. Termico schema (9)
Passi:
1. \(P \approx I_{load} \times (Vin-Vout)\) per LDO se I e V noti; \(T_j \approx T_a + P \theta_{JA}\) con \(T_a=25\) default e campo UI.
2. Resistori: \(P=I^2R\) se I dal LED check o da shunt + V; vs `power_rating_w`.
3. Senza θJA: non inventare; INFO “manca theta_ja”.
### B10. DNP / varianti (10)
Passi:
1. Campo BOM `DNP` / `fitted` / `variant`.
2. Grafo per variante: pin Enable senza pull e senza driver → ERROR.
3. Non è layout.
---
## Wave C — Datasheet intelligence (punto 2)
Restare su DeepSeek Flash vision; niente secondo vendor di default.
### C1. Parsing adattivo (già in parte)
Passi:
1. Eval pintable su 10 PDF: TI, STM, NXP, Espressif, Winbond, GigaDevice, Holtek, Silergy, 3 PEAK.
2. Dove fallisce: skill `extract-pintable` + pagine vision (già keyword). Non RAG vettoriale finché leval non lo chiede.
3. Se serve RAG: chunk per pagina in libreria, retrieval per pin/abs-max — **dopo** C1 eval.
### C2. Errata
Passi:
1. Non scraping indiscriminato (TOS, HTML instabile).
2. Catalogo URL noti (TI `lit/er`, STM `errata`, Microchip).
3. DeepSeek `web_search` **opzionale** gated, citazione obbligatoria, stesso `quote_verify`.
4. Finding `PS-ERRATA-001` se il workaround (pull-up, bond-out) non è nello schema.
**Done when:** un MPN con errata nota in fixture produce finding; vendor senza URL → skip silenzioso loggato.
### C3. OCR / vision block diagram (2)
Passi:
1. Non un modello CV a parte: riusare pagine “block diagram” già in `_PAGE_KEYWORDS`.
2. Tool extraction: `internal_features: {esd_clamp_pins[], pullup_pins[], analog_switch[]}`.
3. Check: pin dichiarato open-drain senza pull visibile.
### C4. Layout rules dal datasheet (placement proposto)
La pagina “Typical application / PCB layout” non è solo uno schema: spesso dice *quanto vicino*, *quanti via*, *da che lato del package*. Va estratto in **struttura**, non lasciato in prosa al reviewer.
Skill o estensione specs (libreria per MPN, versionata come il pintable):
```json
{
"layout_rules": [
{
"kind": "decoupling_proximity",
"pin": "VDD",
"cap_value_hint": "100nF",
"max_distance_mm": 2.0,
"same_layer": true,
"source_page": 14
},
{
"kind": "thermal_via",
"pin": "EP",
"min_via_count": 4
},
{
"kind": "keepout",
"net_class": "analog",
"note": "no digital return under analog pin"
}
]
}
```
Passi:
1. Pagine già keyword-matched (`application`, `layout`, `decoupling`, `PCB`). Vision obbligatoria (disegni).
2. `validate.py` sulla lista: `kind` enum chiuso; distanze solo se il testo/figura ha un numero; altrimenti `max_distance_mm` null e il check G2 usa una default **dichiarata** (es. 3 mm) con severity WARNING.
3. Citazione `source_page` + quote_verify sul testo se c’è (“place within 2 mm”).
4. Non inventare una land pattern JEDEC se il PDF non la dà.
**Done when:** MSPM0 o LDO del `simple_project` ha almeno una `decoupling_proximity` in library JSON, o skip esplicito `layout_rules: []` con log.
---
---
## Wave D — Impedenza analitica (punto 3) *senza* PCB
Motore **standalone**, stile calcolatrice. I vincoli CAD sono export, non verità sul board.
### D1. Motore Wheeler/Schneider
Passi:
1. `pinscopex/impedance.py`: microstrip, stripline, coupled diff, CPW — formule documentate + test numerici vs 3 valori ImpedanceFinder.
2. Input: `h`, `er`, `t`, `w`, `s`, `target_z`.
3. UI tab progetto “Impedance” (non LLM).
### D2. Stackup → regole CAD
Passi:
1. Form stackup (N layer, h, er).
2. Output: w/s per 50 / 90 USB / 100 diff.
3. Export KiCad `.kicad_dru` o netclass — **consigli**, lutente applica.
4. Nessun finding “traccia troppo stretta” finché non c’è `.kicad_pcb`.
---
## Wave E — RF schema (punto 7, parte schema)
Passi:
1. Topologia π/T tra pin ANT e connettore/antenna (stesso matcher filtri).
2. Target 50 Ω come **intento**, non misura: WARNING se manca rete e datasheet mostra matching.
3. CPW clearance: **Wave G** (layout).
---
## Wave F — Supply chain (punto 11)
Passi:
1. Estendere DigiKey/Mouser/LCSC: campi `lifecycle`, `rohs`, `stock`, `lead_time` già spesso nel payload.
2. Job periodico (non ogni review): `lifecycle.json` per MPN libreria.
3. Finding INFO/WARNING EOL/NRND; RoHS fail solo se il flag è esplicito.
4. Cross-ref: **solo** se il distributore dà `replacement` / family; niente LLM “equivalente”.
---
## Wave G — Layout (punti 3 residui, 7 CPW, 8, 10 SI)
Due file per progetto: **schema KiCad flatten** + **`.kicad_pcb`**.
**G0. Ingest layout**
Passi:
1. Parser `.kicad_pcb`: tracce, via, zone, layer, **net name**, footprint, courtyard, differential pairs se presenti.
2. I net del PCB devono combaciare con quelli dello schema dopo il flatten gerarchico (stesso `U1.4` ↔ pad).
3. Modello `LayoutGraph` affiancato a `DesignGraph`.
4. Gerber: non in questa wave. Solo se un utente non può dare il `.kicad_pcb`.
**G1. Check (solo se net e geometria sono legati)**
Passi:
1. Length matching / intra-pair skew vs limite datasheet (USB/HDMI/PCIe).
2. 3W: distanza centro-centro vs W aggressore.
3. Creepage/clearance: profilo IEC 62368 (pollution, RMS V dai net).
4. Isolation barrier: bbox isolator + divieto piste LV nel courtyard HV.
5. CPW: gap verso GND copper vs valore del calcolatore D2.
**G2. Placement vs datasheet (piste, decoupling, thermal)**
Lo schema dice se C12 è sul net VDD. Il PCB dice se C12 è a 8 mm dal pad. Questo check è **solo layout + layout_rules**.
Passi:
1. Per ogni pin alimentazione del pintable: footprint pad xy sul `.kicad_pcb`; condensatori sul medesimo net (grafo); distanza euclidea pad-cap (pin cap verso GND/VDD).
2. Se `max_distance_mm` estratto: ERROR/WARNING se oltre. Se assente: WARNING oltre default configurabile, testo “datasheet non specifica mm; usato default 3 mm”.
3. Via in pad / via sotto EP: contare via nel courtyard del thermal pad vs `min_via_count`.
4. Stesso layer: se `same_layer: true` e il cap è sullaltro lato senza via sotto il pin → WARNING.
5. Piste: lunghezza net VDD dal pin al cap (somma segmenti) come proxy di “loop area”; se >> distanza euclidea, c’è un giro largo.
6. Crystal: cap load vs pin XIN/XOUT (stessa metrica di distanza), se X1 è nel grafo.
7. Finding `PS-PLC-001` con `pins`, `net`, pagina datasheet. Plugin: focus footprint su pcbnew.
Non confrontare una foto del layout TI con il board pixel-a-pixel. Solo vincoli numerici/topologici.
**Done when:** fixture PCB con C di decoupling a 15 mm da VDD (regola 2 mm) → `PS-PLC-001`; cap a 1 mm → niente finding.
**Done when (G1+G2):** un `.kicad_pcb` di test (USB diff pair volutamente sbagliata) produce `PS-SI-001`. I net coincidono con lo schema gerarchico della stessa repo.
---
## Wave H — Enterprise (punto 12)
Passi:
1. Stato finding: `open | false_positive | accepted | wontfix` + motivo obbligatorio.
2. Firma rilascio: hash report + user + timestamp (OSS: locale; cloud: già Clerk).
3. ECO: export `eco.json` / CSV: rule_id, ref, before/after raccomandazione, finding_id.
4. Dashboard: filtri già URL; aggiungere coda “da approvare”.
Commenti esistono: non rifarli, agganciarli allo stato.
---
## Ordine di esecuzione (sviluppo)
Non parallelizzare A0 schema finding con G.
| Sprint (indicativo) | Contenuto | Dipende da |
| --- | --- | --- |
| 0 | Schema finding + eval + smoke V4.1 | — |
| 1 | A1 KiCad GA + A2 BOM match | 0 |
| 2 | B3B5 pull-up/decoupling/Cin-Cout | 0 |
| 3 | B6B9 DC bias, ESR, filtri, termico | 2 |
| 4 | B1B2 power drop/sequencing + B10 DNP | 1 |
| 5 | A3 plugin KiCad + E2 JSON | 1, 0 |
| 6 | C2 errata + C3 internal features + **C4 layout_rules** | eval C1 |
| 7 | D1D2 calcolatrice impedenza + export netclass | — |
| 8 | F lifecycle | APIs già presenti |
| 9 | H review workflow / ECO | 0 |
| 10+ | G layout `.kicad_pcb` + G2 placement datasheet | A1 + C4 |
Stima onesta: Wave AB (schema) sono il ritorno; G è un secondo prodotto. Non promettere creepage nel plugin schema.
---
## Passi operativi per **ogni** check nuovo
1. Fixture grafo minimo in `tests/test_<nome>.py` (non solo `simple_project`).
2. Funzione in `pinscopex/` senza I/O.
3. Registrare in `services/validation.py` accanto a pin_mux/LED.
4. `rule_id` + `source`.
5. Una riga changelog.
6. Se tocca UI: tab o badge “Automated check” già usato.
7. Browser solo se UI; senno pytest.
---
## Fuori scope (esplicito)
- Plugin o parser **EasyEDA**.
- Agente che **progetta** lo sbroglio.
- Simulazione SPICE/IBIS.
- Sostituti pin-to-pin inventati dallLLM.
- Scraping errata senza whitelist.
- Layout da netlist PADS dump (`!PADS-POWERPCB`) — già rifiutato.
---
## Prossimo passo concreto (quando si apre lo sviluppo)
Sprint 0, in questordine:
1. Estensione `Finding` (`rule_id`, `pins`, `net`).
2. Eval script `simple_project` (finding count + citation).
3. Flatten `.kicad_sch` gerarchico (fixture multi-foglio).
4. Upload `.kicad_pcb` accanto allo schema (parse, ancora senza check SI).
5. BOM mismatch finding.
Il plugin KiCad aspetta uuid + sheet path. Placement decoupling in mm e 3W aspettano `.kicad_pcb` + `layout_rules`.
@@ -46,6 +46,7 @@ import {
deleteProject, deleteProject,
uploadBom, uploadBom,
uploadNetlist, uploadNetlist,
uploadPcb,
uploadDatasheet, uploadDatasheet,
startPipeline, startPipeline,
checkLibrary, checkLibrary,
@@ -548,6 +549,7 @@ export function CreateProjectDialog({
const [name, setName] = useState(""); const [name, setName] = useState("");
const [bomFile, setBomFile] = useState<File | null>(null); const [bomFile, setBomFile] = useState<File | null>(null);
const [netlistFile, setNetlistFile] = useState<File | null>(null); const [netlistFile, setNetlistFile] = useState<File | null>(null);
const [pcbFile, setPcbFile] = useState<File | null>(null);
const [netlistNetCount, setNetlistNetCount] = useState<number | null>(null); const [netlistNetCount, setNetlistNetCount] = useState<number | null>(null);
const [netlistError, setNetlistError] = useState<string | null>(null); const [netlistError, setNetlistError] = useState<string | null>(null);
@@ -1879,6 +1881,10 @@ export function CreateProjectDialog({
setProgress("Uploading netlist..."); setProgress("Uploading netlist...");
await uploadNetlist(project.id, netlistFile!); await uploadNetlist(project.id, netlistFile!);
} }
if (pcbFile) {
setProgress("Uploading PCB...");
await uploadPcb(project.id, pcbFile);
}
} catch (uploadErr) { } catch (uploadErr) {
if (!projectAlreadyExists) { if (!projectAlreadyExists) {
try { try {
@@ -2112,6 +2118,19 @@ export function CreateProjectDialog({
)} )}
</div> </div>
</div> </div>
<div className="space-y-1.5">
<FileUploadZone
label="PCB (.kicad_pcb, optional)"
accept=".kicad_pcb"
files={pcbFile ? [pcbFile] : []}
onFilesChange={(files) => setPcbFile(files[0] ?? null)}
/>
<p className="text-[11px] text-muted-foreground leading-tight px-1">
Optional. Layout checks (trace width, 3W, placement mm) stay
off until a board is present. Schema review still runs
without it.
</p>
</div>
</div> </div>
)} )}
+17
View File
@@ -61,6 +61,7 @@ function mapProject(p: Record<string, unknown>): Project {
summary: p.summary as Record<string, number> | undefined, summary: p.summary as Record<string, number> | undefined,
hasNetlist: p.has_netlist as boolean, hasNetlist: p.has_netlist as boolean,
hasBom: p.has_bom as boolean, hasBom: p.has_bom as boolean,
hasPcb: (p.has_pcb as boolean | undefined) ?? false,
datasheetCount: p.datasheet_count as number, datasheetCount: p.datasheet_count as number,
skippedComponents: (p.skipped_components as SkippedComponent[] | null) ?? undefined, skippedComponents: (p.skipped_components as SkippedComponent[] | null) ?? undefined,
completedReviewRefs: (p.completed_review_refs as string[] | null) ?? undefined, completedReviewRefs: (p.completed_review_refs as string[] | null) ?? undefined,
@@ -277,6 +278,22 @@ export interface UploadNetlistResult {
designator_pins: NetlistPreviewDesignator[]; designator_pins: NetlistPreviewDesignator[];
} }
export async function uploadPcb(
projectId: string, file: File,
): Promise<{ path: string; footprints: number; nets: number; segments: number }> {
const form = new FormData();
form.append("file", file);
const res = await authFetch(
`${BASE}/api/projects/${projectId}/upload/pcb`,
{ method: "POST", body: form },
);
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: "Upload failed" }));
throw new Error(err.detail || "Failed to upload PCB");
}
return res.json();
}
export async function uploadNetlist( export async function uploadNetlist(
projectId: string, file: File, projectId: string, file: File,
): Promise<UploadNetlistResult> { ): Promise<UploadNetlistResult> {
+7 -1
View File
@@ -12,10 +12,13 @@ const HEADER = [
"Description", "Description",
"Recommendation", "Recommendation",
"Source", "Source",
"Net",
"Pins",
"Rule",
]; ];
// Column widths (in characters), aligned with HEADER order. // Column widths (in characters), aligned with HEADER order.
const COL_WIDTHS = [12, 20, 12, 10, 44, 60, 50, 24]; const COL_WIDTHS = [12, 20, 12, 10, 44, 60, 50, 24, 16, 16, 14];
// Fold the datasheet reference + page number into a single cell, mirroring the // Fold the datasheet reference + page number into a single cell, mirroring the
// finding card's reference button + "Automated check" tag logic. // finding card's reference button + "Automated check" tag logic.
@@ -47,6 +50,9 @@ export function exportReportToExcel(
f.why ?? "", f.why ?? "",
f.recommendation ?? "", f.recommendation ?? "",
formatSource(f), formatSource(f),
f.net ?? "",
(f.pins ?? []).join(", "),
f.rule_id ?? "",
]); ]);
const ws = XLSX.utils.aoa_to_sheet([HEADER, ...rows]); const ws = XLSX.utils.aoa_to_sheet([HEADER, ...rows]);
+9
View File
@@ -14,6 +14,12 @@ export interface Finding {
recommendation?: string; recommendation?: string;
reference: string; reference: string;
source?: string | null; // pin_mux_check / led_current_check / supply_decoupling_check / i2c_pullup_check / reset_pullup_check = deterministic; null/"review" = LLM source?: string | null; // pin_mux_check / led_current_check / supply_decoupling_check / i2c_pullup_check / reset_pullup_check = deterministic; null/"review" = LLM
net?: string | null;
pins?: string[];
rule_id?: string | null;
cad_sheet?: string | null;
cad_uuid?: string | null;
variant?: string | null;
} }
export interface FindingComment { export interface FindingComment {
@@ -88,6 +94,8 @@ export interface Net {
export interface DesignGraph { export interface DesignGraph {
components: Record<string, Component>; components: Record<string, Component>;
nets: Record<string, Net>; nets: Record<string, Net>;
bom_fields?: Record<string, { mpn?: string | null; value?: string }>;
schematic_fields?: Record<string, { mpn?: string | null; value?: string }>;
} }
export interface BomSummaryRow { export interface BomSummaryRow {
@@ -226,6 +234,7 @@ export interface Project {
summary?: Record<string, number>; summary?: Record<string, number>;
hasNetlist: boolean; hasNetlist: boolean;
hasBom: boolean; hasBom: boolean;
hasPcb?: boolean;
datasheetCount: number; datasheetCount: number;
skippedComponents?: SkippedComponent[]; skippedComponents?: SkippedComponent[];
completedReviewRefs?: string[]; completedReviewRefs?: string[];
+9
View File
@@ -0,0 +1,9 @@
{
"required_refs": ["U1", "U2", "U3"],
"min_components": 30,
"min_nets": 30,
"deterministic_keys": [
"PS-I2C-001|U3|/I2C0.SDA",
"PS-I2C-001|U3|/I2C0.SCL"
]
}
+107
View File
@@ -0,0 +1,107 @@
"""BOM vs schematic MPN/value match.
Favor: identical MPNs silent; real mismatch is ERROR PS-BOM-001 with
designator; orphan BOM line is WARNING.
Against: case/whitespace-only MPN is not a mismatch; empty schematic map
skips the check (PADS path); BOM-empty + schematic MPN is fill, not ERROR.
"""
from __future__ import annotations
from backend.pinscopex.bom_match_check import check_bom_schematic_match
from backend.pinscopex.models import DesignGraph
def test_matching_mpns_produce_no_findings():
sch = {"U1": {"mpn": "SPX3819M5-L-3-3", "value": "3.3V LDO"}}
bom = {"U1": {"mpn": "SPX3819M5-L-3-3", "value": "3.3V LDO"}}
assert check_bom_schematic_match(sch, bom) == []
def test_mpn_mismatch_is_error_ps_bom_001():
sch = {"U1": {"mpn": "SPX3819M5-L-3-3", "value": "LDO"}}
bom = {"U1": {"mpn": "AMS1117-3.3", "value": "LDO"}}
findings = check_bom_schematic_match(sch, bom)
assert len(findings) == 1
f = findings[0]
assert f.rule_id == "PS-BOM-001"
assert f.source == "bom_match"
assert f.status == "ERROR"
assert f.designator == "U1"
assert "SPX3819M5-L-3-3" in f.finding
assert "AMS1117-3.3" in f.finding
def test_orphan_bom_ref_is_warning():
sch = {"U1": {"mpn": "MCUX", "value": "MCU"}}
bom = {
"U1": {"mpn": "MCUX", "value": "MCU"},
"R99": {"mpn": "RC0603", "value": "10k"},
}
findings = check_bom_schematic_match(sch, bom)
assert len(findings) == 1
assert findings[0].rule_id == "PS-BOM-002"
assert findings[0].status == "WARNING"
assert findings[0].designator == "R99"
def test_mpn_case_and_whitespace_are_not_a_mismatch():
sch = {"U1": {"mpn": " mspm0g3507sptr ", "value": "MCU"}}
bom = {"U1": {"mpn": "MSPM0G3507SPTR", "value": "MCU"}}
assert check_bom_schematic_match(sch, bom) == []
def test_empty_bom_mpn_with_schematic_mpn_is_not_a_mismatch():
sch = {"U1": {"mpn": "MSPM0G3507SPTR", "value": "MCU"}}
bom = {"U1": {"mpn": None, "value": "MSPM0"}}
assert check_bom_schematic_match(sch, bom) == []
def test_empty_schematic_map_skips_check():
"""PADS/EDIF graphs have no schematic property table — do not treat
every BOM line as an orphan."""
sch = {}
bom = {"U1": {"mpn": "MCUX", "value": "MCU"}, "R1": {"mpn": "RC", "value": "10k"}}
assert check_bom_schematic_match(sch, bom) == []
def test_legacy_design_graph_without_source_fields_still_validates():
g = DesignGraph.model_validate({
"components": {},
"nets": {},
})
assert g.bom_fields == {}
assert g.schematic_fields == {}
def test_build_graph_kicad_mpn_mismatch_surfaces(tmp_path):
from backend.pinscopex.graph import build_graph
net = tmp_path / "net.xml"
net.write_text(
"""<?xml version="1.0" encoding="UTF-8"?>
<export version="E">
<components>
<comp ref="U1">
<value>LDO</value>
<fields><field name="MPN">SPX3819M5-L-3-3</field></fields>
</comp>
</components>
<nets>
<net code="1" name="GND"><node ref="U1" pin="2"/></net>
</nets>
</export>
"""
)
bom = tmp_path / "bom.csv"
bom.write_text(
"Reference,Value,Footprint,Manufacturer Part Number\n"
"U1,LDO,,AMS1117-3.3\n"
)
g = build_graph(
net, bom, tmp_path / "empty_ex", tmp_path / "empty_pat", tmp_path / "empty_mod",
)
findings = check_bom_schematic_match(g.schematic_fields, g.bom_fields)
assert len(findings) == 1
assert findings[0].rule_id == "PS-BOM-001"
assert findings[0].designator == "U1"
+24
View File
@@ -65,6 +65,30 @@ def test_singletons_pass_through_unchanged():
assert [f.designator for f in built] == ["U2", "U3"] assert [f.designator for f in built] == ["U2", "U3"]
def test_dedupe_passthrough_keeps_cad_fields():
originals = [
Finding(
designator="U2",
mpn="MPN1",
finding="finding 1",
why="w",
status="ERROR",
recommendation="",
source_page=1,
reference="ref 1",
net="USB_D+",
pins=["U2.1"],
rule_id="PS-USB-001",
)
]
groups = [{"member_indices": [1], "change_rationale": "passthrough"}]
built = _build_deduped(groups, originals)
assert built is not None
assert built[0].rule_id == "PS-USB-001"
assert built[0].net == "USB_D+"
assert built[0].pins == ["U2.1"]
def test_merge_collapses_interface_and_uses_primary_source(): def test_merge_collapses_interface_and_uses_primary_source():
"""U2-001 + U3-001 → one finding. primary_index picks which side supplies """U2-001 + U3-001 → one finding. primary_index picks which side supplies
the canonical designator + datasheet citation.""" the canonical designator + datasheet citation."""
+104
View File
@@ -0,0 +1,104 @@
"""Eval harness — finding count, citation hit-rate, precision/recall.
Favor: perfect golden match; citation rate ignores deterministic findings;
simple_project graph still has U1/U2/U3 and the two I2C pull-up keys.
Against: extra finding drops precision; missing golden key drops recall;
Unverified quotes are citation misses, not hits.
"""
from __future__ import annotations
from pathlib import Path
from backend.pinscopex.eval_report import (
citation_hit_rate,
eval_simple_project,
finding_key,
score_report,
)
from backend.pinscopex.models import Finding
def _f(**kwargs) -> Finding:
defaults = dict(designator="U1", finding="x", status="WARNING")
defaults.update(kwargs)
return Finding(**defaults)
def test_perfect_match_is_precision_and_recall_one():
f = _f(rule_id="PS-I2C-001", designator="U3", net="/I2C0.SDA")
scores = score_report([f], {finding_key(f)})
assert scores.precision == 1.0
assert scores.recall == 1.0
assert scores.finding_count == 1
assert scores.extra_keys == []
assert scores.missing_keys == []
def test_extra_finding_drops_precision_not_recall():
gold = _f(rule_id="PS-I2C-001", designator="U3", net="/I2C0.SDA")
extra = _f(rule_id="PS-BOM-001", designator="U1", net="")
scores = score_report([gold, extra], {finding_key(gold)})
assert scores.recall == 1.0
assert scores.precision == 0.5
assert scores.extra_keys == ["PS-BOM-001|U1|"]
def test_missing_golden_key_drops_recall():
gold_a = "PS-I2C-001|U3|/I2C0.SDA"
gold_b = "PS-I2C-001|U3|/I2C0.SCL"
produced = [_f(rule_id="PS-I2C-001", designator="U3", net="/I2C0.SDA")]
scores = score_report([produced[0]], {gold_a, gold_b})
assert scores.precision == 1.0
assert scores.recall == 0.5
assert scores.missing_keys == [gold_b]
def test_citation_rate_ignores_deterministic_and_counts_unverified():
det = _f(
source="i2c_pullup_check",
rule_id="PS-I2C-001",
source_quote="ignored because deterministic",
why="no pull-up",
)
ok = _f(
source="review",
source_quote="Connect a 100 nF capacitor close to VDD.",
why="missing cap",
status="ERROR",
)
bad = _f(
source="review",
source_quote="This quote is long enough to count.",
why="Unverified: cited text not found in the datasheet.",
status="WARNING",
)
assert citation_hit_rate([det, ok, bad]) == 0.5
scores = score_report([det, ok, bad], {finding_key(det)})
assert scores.unverified_pct == 100.0 / 3
assert scores.citation_hit_rate == 0.5
def test_simple_project_eval_matches_committed_golden():
root = Path(__file__).resolve().parents[1] / "simple_project"
scores = eval_simple_project(root)
assert scores.graph_ok, scores.graph_errors
assert scores.precision == 1.0
assert scores.recall == 1.0
assert scores.finding_count == 2
assert scores.by_status["WARNING"] == 2
def test_simple_project_eval_rejects_truncated_graph(tmp_path: Path):
import json
import shutil
src = Path(__file__).resolve().parents[1] / "simple_project"
dest = tmp_path / "simple_project"
shutil.copytree(src, dest)
g = json.loads((dest / "design_graph.json").read_text())
g["components"] = {"R1": g["components"]["R1"]}
(dest / "design_graph.json").write_text(json.dumps(g))
scores = eval_simple_project(dest)
assert scores.graph_ok is False
assert any("U1" in e or "missing ref" in e for e in scores.graph_errors)
+84
View File
@@ -0,0 +1,84 @@
"""Finding schema — optional CAD/plugin fields, backward compatible.
Favor: legacy JSON still validates; new fields round-trip; pin-mux
fills rule_id + net + pins.
Against: invalid status rejected; pins must be a list; extra junk status
does not silently coerce.
"""
from __future__ import annotations
import json
import pytest
from pydantic import ValidationError
from backend.pinscopex.models import Finding
def test_legacy_json_without_new_fields_still_validates():
raw = {
"designator": "U3",
"mpn": "MSPM0G3507SPTR",
"finding": "Missing decoupling",
"why": "Datasheet requires 100n close to VDD",
"status": "ERROR",
"reference": "p.12",
}
f = Finding.model_validate(raw)
assert f.net is None
assert f.pins == []
assert f.rule_id is None
assert f.cad_sheet is None
assert f.cad_uuid is None
assert f.variant is None
def test_new_fields_round_trip_json():
f = Finding(
designator="U1",
mpn="SPX3819",
finding="Cin too far",
status="WARNING",
net="VIN",
pins=["U1.1", "C1.1"],
rule_id="PS-DEC-001",
cad_sheet="power.kicad_sch",
cad_uuid="aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
variant="DNP",
)
dumped = json.loads(f.model_dump_json())
again = Finding.model_validate(dumped)
assert again.net == "VIN"
assert again.pins == ["U1.1", "C1.1"]
assert again.rule_id == "PS-DEC-001"
assert again.cad_sheet == "power.kicad_sch"
assert again.cad_uuid == "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
assert again.variant == "DNP"
def test_unknown_extra_keys_do_not_break_legacy_payloads():
f = Finding.model_validate(
{
"designator": "R1",
"finding": "ok",
"status": "INFO",
"future_field_from_old_report": True,
}
)
assert f.designator == "R1"
def test_invalid_status_is_rejected():
with pytest.raises(ValidationError):
Finding(designator="U1", finding="x", status="error")
def test_pins_must_be_a_list_not_a_string():
with pytest.raises(ValidationError):
Finding(designator="U1", finding="x", status="INFO", pins="U1.1")
def test_status_ok_is_not_silently_accepted():
with pytest.raises(ValidationError):
Finding(designator="U1", finding="x", status="OK")
+222
View File
@@ -114,3 +114,225 @@ def test_kicad_mpn_fills_empty_bom(tmp_path: Path):
) )
assert g.components["U1"].mpn == "MSPM0G3507SPTR" assert g.components["U1"].mpn == "MSPM0G3507SPTR"
assert "GND" in g.nets assert "GND" in g.nets
# ---------------------------------------------------------------------------
# Hierarchical .kicad_sch (root + child sheets)
# ---------------------------------------------------------------------------
_LIB_R = """
(lib_symbols
(symbol "Device:R"
(pin passive (at 0 3.81 90) (length 2.54)
(name "~" (effects (font (size 1.27 1.27))))
(number "1" (effects (font (size 1.27 1.27))))
)
(pin passive (at 0 -3.81 90) (length 2.54)
(name "~" (effects (font (size 1.27 1.27))))
(number "2" (effects (font (size 1.27 1.27))))
)
)
)
"""
def _resistor(ref: str, value: str, x: float = 0, y: float = 0) -> str:
return f"""
(symbol
(lib_id "Device:R")
(at {x} {y} 0)
(unit 1)
(uuid "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
(property "Reference" "{ref}" (at 0 0 0) (effects (font (size 1.27 1.27))))
(property "Value" "{value}" (at 0 0 0) (effects (font (size 1.27 1.27))))
(pin "1" (uuid "p1"))
(pin "2" (uuid "p2"))
)
"""
def _sch(*body: str) -> str:
return "(kicad_sch (version 20250114) (uuid \"11111111-1111-1111-1111-111111111111\")" + _LIB_R + "".join(body) + "\n)\n"
def test_parse_single_sheet_kicad_sch(tmp_path: Path):
from backend.pinscopex.parsers import parse_netlist_any
p = tmp_path / "one.kicad_sch"
p.write_text(_sch(
_resistor("R1", "10k"),
"""
(global_label "GND" (at 0 3.81 0) (uuid "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"))
""",
))
parts, nets, fmt = parse_netlist_any(p)
assert fmt == "kicad_sch"
assert "R1" in parts
assert ("R1", "1") in nets["GND"]
def test_hierarchical_global_gnd_merges_across_sheets(tmp_path: Path):
from backend.pinscopex.parsers import parse_netlist_any
child = tmp_path / "child.kicad_sch"
child.write_text(_sch(
_resistor("C1", "100n"),
"""
(global_label "GND" (at 0 3.81 0) (uuid "cccccccccccccccccccccccccccccccccccc"))
""",
))
root = tmp_path / "root.kicad_sch"
root.write_text(_sch(
_resistor("R1", "10k"),
"""
(global_label "GND" (at 0 3.81 0) (uuid "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"))
(sheet
(at 50 0)
(size 20 20)
(property "Sheetname" "Child" (at 50 0 0) (effects (font (size 1.27 1.27))))
(property "Sheetfile" "child.kicad_sch" (at 50 0 0) (effects (font (size 1.27 1.27))))
)
""",
))
_parts, nets, fmt = parse_netlist_any(root)
assert fmt == "kicad_sch"
gnd = nets["GND"]
assert ("R1", "1") in gnd
assert ("C1", "1") in gnd
def test_hierarchical_label_connects_through_sheet_pin(tmp_path: Path):
from backend.pinscopex.parsers import parse_netlist_any
child = tmp_path / "analog.kicad_sch"
child.write_text(_sch(
_resistor("C1", "100n"),
"""
(hierarchical_label "VIN" (at 0 3.81 0) (uuid "cccccccccccccccccccccccccccccccccccc"))
""",
))
root = tmp_path / "root.kicad_sch"
root.write_text(_sch(
_resistor("R1", "10k"),
"""
(wire (pts (xy 0 3.81) (xy 50 3.81)))
(sheet
(at 50 0)
(size 20 20)
(property "Sheetname" "Analog" (at 50 0 0) (effects (font (size 1.27 1.27))))
(property "Sheetfile" "analog.kicad_sch" (at 50 0 0) (effects (font (size 1.27 1.27))))
(pin "VIN" unspecified (at 50 3.81 180) (uuid "dddddddd-dddd-dddd-dddd-dddddddddddd"))
)
""",
))
_parts, nets, _fmt = parse_netlist_any(root)
vin = nets["VIN"]
assert ("R1", "1") in vin
assert ("C1", "1") in vin
def test_local_labels_same_name_do_not_merge_across_sheets(tmp_path: Path):
from backend.pinscopex.parsers import parse_netlist_any
child = tmp_path / "child.kicad_sch"
child.write_text(_sch(
_resistor("R2", "1k"),
"""
(label "FOO" (at 0 3.81 0) (uuid "cccccccccccccccccccccccccccccccccccc"))
""",
))
root = tmp_path / "root.kicad_sch"
root.write_text(_sch(
_resistor("R1", "10k"),
"""
(label "FOO" (at 0 3.81 0) (uuid "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"))
(sheet
(at 50 0)
(size 20 20)
(property "Sheetname" "Child" (at 50 0 0) (effects (font (size 1.27 1.27))))
(property "Sheetfile" "child.kicad_sch" (at 50 0 0) (effects (font (size 1.27 1.27))))
)
""",
))
_parts, nets, _fmt = parse_netlist_any(root)
r1_nets = [n for n, pins in nets.items() if ("R1", "1") in pins]
r2_nets = [n for n, pins in nets.items() if ("R2", "1") in pins]
assert len(r1_nets) == 1 and len(r2_nets) == 1
assert r1_nets[0] != r2_nets[0]
assert "FOO" not in nets or len(nets.get("FOO", [])) <= 1
def test_sheetfile_parent_traversal_is_rejected(tmp_path: Path):
import pytest
from backend.pinscopex.parsers_kicad import parse_kicad
root = tmp_path / "root.kicad_sch"
root.write_text(_sch(
_resistor("R1", "10k"),
"""
(global_label "GND" (at 0 3.81 0) (uuid "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"))
(sheet
(at 50 0)
(size 20 20)
(property "Sheetname" "Escape" (at 50 0 0) (effects (font (size 1.27 1.27))))
(property "Sheetfile" "../outside.kicad_sch" (at 50 0 0) (effects (font (size 1.27 1.27))))
)
""",
))
with pytest.raises(ValueError, match="rejected"):
parse_kicad(root)
def test_missing_child_sheet_raises(tmp_path: Path):
import pytest
from backend.pinscopex.parsers_kicad import parse_kicad
root = tmp_path / "root.kicad_sch"
root.write_text(_sch(
_resistor("R1", "10k"),
"""
(global_label "GND" (at 0 3.81 0) (uuid "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"))
(sheet
(at 50 0)
(size 20 20)
(property "Sheetname" "Missing" (at 50 0 0) (effects (font (size 1.27 1.27))))
(property "Sheetfile" "nope.kicad_sch" (at 50 0 0) (effects (font (size 1.27 1.27))))
)
""",
))
with pytest.raises(ValueError, match="Missing"):
parse_kicad(root)
def test_cyclic_sheet_include_is_rejected(tmp_path: Path):
import pytest
from backend.pinscopex.parsers_kicad import parse_kicad
child = tmp_path / "child.kicad_sch"
child.write_text(_sch(
_resistor("C1", "100n"),
"""
(global_label "GND" (at 0 3.81 0) (uuid "cccccccccccccccccccccccccccccccccccc"))
(sheet
(at 50 0)
(size 20 20)
(property "Sheetname" "Root" (at 50 0 0) (effects (font (size 1.27 1.27))))
(property "Sheetfile" "root.kicad_sch" (at 50 0 0) (effects (font (size 1.27 1.27))))
)
""",
))
root = tmp_path / "root.kicad_sch"
root.write_text(_sch(
_resistor("R1", "10k"),
"""
(global_label "GND" (at 0 3.81 0) (uuid "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"))
(sheet
(at 50 0)
(size 20 20)
(property "Sheetname" "Child" (at 50 0 0) (effects (font (size 1.27 1.27))))
(property "Sheetfile" "child.kicad_sch" (at 50 0 0) (effects (font (size 1.27 1.27))))
)
""",
))
with pytest.raises(ValueError, match="[Cc]yclic"):
parse_kicad(root)
+160
View File
@@ -0,0 +1,160 @@
"""KiCad PCB ingest — parse only, no SI/creepage checks.
Favor: footprint+pad net; named nets; segment on a net.
Against: .kicad_sch rejected; missing Reference skipped; empty board ok.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from backend.pinscopex.parsers_kicad_pcb import parse_kicad_pcb
_PCB = """(kicad_pcb (version 20240108) (generator pcbnew)
(net 0 "")
(net 1 "GND")
(net 2 "+3V3")
(footprint "Resistor_SMD:R_0603_1608Metric"
(layer "F.Cu")
(at 10 20 0)
(property "Reference" "R1" (at 0 0 0) (effects (font (size 1 1))))
(property "Value" "10k" (at 0 0 0) (effects (font (size 1 1))))
(pad "1" smd roundrect (at -0.75 0) (size 0.8 0.9) (layers "F.Cu") (net 2 "+3V3"))
(pad "2" smd roundrect (at 0.75 0) (size 0.8 0.9) (layers "F.Cu") (net 1 "GND"))
)
(footprint "Resistor_SMD:R_0603_1608Metric"
(layer "F.Cu")
(at 0 0 0)
(property "Reference" "#PWR01" (at 0 0 0) (effects (font (size 1 1))))
(pad "1" smd rect (at 0 0) (size 1 1) (layers "F.Cu") (net 1 "GND"))
)
(segment (start 10 20) (end 12 20) (width 0.25) (layer "F.Cu") (net 1))
(via (at 11 20) (size 0.8) (drill 0.4) (layers "F.Cu" "B.Cu") (net 1))
)
"""
def test_parse_footprint_pads_and_nets(tmp_path: Path):
p = tmp_path / "board.kicad_pcb"
p.write_text(_PCB)
g = parse_kicad_pcb(p)
assert "GND" in g.nets and "+3V3" in g.nets
assert "R1" in g.footprints
r1 = g.footprints["R1"]
assert r1.x == 10 and r1.y == 20
by_num = {pad.number: pad for pad in r1.pads}
assert by_num["1"].net == "+3V3"
assert by_num["2"].net == "GND"
assert by_num["1"].x == pytest.approx(9.25)
assert by_num["2"].x == pytest.approx(10.75)
def test_parse_segment_and_via_resolve_net_name(tmp_path: Path):
p = tmp_path / "board.kicad_pcb"
p.write_text(_PCB)
g = parse_kicad_pcb(p)
assert len(g.segments) == 1
assert g.segments[0].net == "GND"
assert len(g.vias) == 1
assert g.vias[0].net == "GND"
def test_power_flag_footprint_is_skipped(tmp_path: Path):
p = tmp_path / "board.kicad_pcb"
p.write_text(_PCB)
g = parse_kicad_pcb(p)
assert "#PWR01" not in g.footprints
def test_kicad_sch_is_rejected_as_pcb(tmp_path: Path):
p = tmp_path / "sheet.kicad_sch"
p.write_text("(kicad_sch (version 20250114) (uuid \"1\"))\n")
with pytest.raises(ValueError, match="kicad_pcb"):
parse_kicad_pcb(p)
def test_empty_board_parses(tmp_path: Path):
p = tmp_path / "empty.kicad_pcb"
p.write_text("(kicad_pcb (version 20240108) (generator pcbnew))\n")
g = parse_kicad_pcb(p)
assert g.footprints == {}
assert g.segments == []
def test_upload_pcb_sets_has_pcb(tmp_path: Path):
from fastapi.testclient import TestClient
from backend.main import app
from backend.services.storage import LocalStorageBackend
app.state.storage = LocalStorageBackend(tmp_path)
client = TestClient(app)
pid = client.post("/api/projects", json={"name": "board"}).json()["id"]
resp = client.post(
f"/api/projects/{pid}/upload/pcb",
files={"file": ("board.kicad_pcb", _PCB.encode(), "text/plain")},
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["footprints"] == 1
assert body["nets"] >= 2
fresh = client.get(f"/api/projects/{pid}").json()
assert fresh["has_pcb"] is True
assert fresh["has_netlist"] is False
def test_upload_sch_as_pcb_is_rejected(tmp_path: Path):
from fastapi.testclient import TestClient
from backend.main import app
from backend.services.storage import LocalStorageBackend
app.state.storage = LocalStorageBackend(tmp_path)
client = TestClient(app)
pid = client.post("/api/projects", json={"name": "board"}).json()["id"]
resp = client.post(
f"/api/projects/{pid}/upload/pcb",
files={"file": ("sheet.kicad_sch", b"(kicad_sch (version 1))\n", "text/plain")},
)
assert resp.status_code == 400
fresh = client.get(f"/api/projects/{pid}").json()
assert not fresh.get("has_pcb")
class _FakeWs:
def __init__(self, root: Path):
self.root = root
def local_path(self, rel: str) -> Path:
return self.root / rel
def test_layout_graph_skipped_when_pcb_missing(tmp_path: Path):
from backend.services.pipeline import _write_layout_graph
(tmp_path / "uploads").mkdir()
_write_layout_graph(_FakeWs(tmp_path), "p1")
assert not (tmp_path / "layout_graph.json").is_file()
def test_layout_graph_fail_soft_on_invalid_pcb(tmp_path: Path):
from backend.services.pipeline import _write_layout_graph
uploads = tmp_path / "uploads"
uploads.mkdir()
(uploads / "pcb.kicad_pcb").write_text("(kicad_sch (version 1))\n")
_write_layout_graph(_FakeWs(tmp_path), "p1")
assert not (tmp_path / "layout_graph.json").is_file()
def test_layout_graph_written_from_valid_pcb(tmp_path: Path):
from backend.services.pipeline import _write_layout_graph
uploads = tmp_path / "uploads"
uploads.mkdir()
(uploads / "pcb.kicad_pcb").write_text(_PCB)
_write_layout_graph(_FakeWs(tmp_path), "p1")
out = tmp_path / "layout_graph.json"
assert out.is_file()
data = __import__("json").loads(out.read_text())
assert "R1" in data["footprints"]
+30
View File
@@ -50,6 +50,36 @@ def test_serialize_for_prompt_shows_reviewer_severity():
assert [row["reviewer_severity"] for row in parsed] == ["ERROR", "INFO"] assert [row["reviewer_severity"] for row in parsed] == ["ERROR", "INFO"]
def test_normalize_passthrough_keeps_cad_fields():
originals = [
Finding(
designator="U3",
mpn="X",
finding="finding 1",
why="w",
status="WARNING",
recommendation="",
source_page=1,
reference="",
net="UART5_TX",
pins=["U3.54"],
rule_id="PS-MUX-001",
cad_sheet="mcu.kicad_sch",
)
]
raw_findings = [{
"merged_from": [1], "finding": "finding 1", "why": "w",
"status": "WARNING", "recommendation": "", "change_rationale": "unchanged",
}]
built = _build_normalized(raw_findings, [], originals)
assert built is not None
kept, _ = built
assert kept[0].rule_id == "PS-MUX-001"
assert kept[0].net == "UART5_TX"
assert kept[0].pins == ["U3.54"]
assert kept[0].cad_sheet == "mcu.kicad_sch"
def test_schema_exposes_dropped_array_and_single_fix_field(): def test_schema_exposes_dropped_array_and_single_fix_field():
props = SUBMIT_NORMALIZED_SCHEMA.input_schema["properties"] props = SUBMIT_NORMALIZED_SCHEMA.input_schema["properties"]
assert "dropped" in props assert "dropped" in props
+2
View File
@@ -104,6 +104,8 @@ def test_i2c_missing_pullup():
findings = check_i2c_pullups(g, cons) findings = check_i2c_pullups(g, cons)
assert len(findings) == 1 assert len(findings) == 1
assert findings[0].source == "i2c_pullup_check" assert findings[0].source == "i2c_pullup_check"
assert findings[0].rule_id == "PS-I2C-001"
assert findings[0].net == "I2C_SDA"
def test_i2c_pullup_present(): def test_i2c_pullup_present():
+3
View File
@@ -69,6 +69,9 @@ def test_real_defect_uart5_swapped_is_error():
assert {f.designator for f in findings} == {"U3"} assert {f.designator for f in findings} == {"U3"}
tx = next(f for f in findings if "MCU-UART5-TX" in f.finding) tx = next(f for f in findings if "MCU-UART5-TX" in f.finding)
assert "cannot be muxed as UART5_TX" in tx.finding assert "cannot be muxed as UART5_TX" in tx.finding
assert tx.rule_id == "PS-MUX-001"
assert tx.net == "MCU-UART5-TX"
assert tx.pins == ["U3.54"]
def test_correct_assignment_no_finding(): def test_correct_assignment_no_finding():