diff --git a/backend/pinscopex/bom_match_check.py b/backend/pinscopex/bom_match_check.py new file mode 100644 index 0000000..febd943 --- /dev/null +++ b/backend/pinscopex/bom_match_check.py @@ -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 diff --git a/backend/pinscopex/eval_report.py b/backend/pinscopex/eval_report.py new file mode 100644 index 0000000..4cf4c5e --- /dev/null +++ b/backend/pinscopex/eval_report.py @@ -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) diff --git a/backend/pinscopex/graph.py b/backend/pinscopex/graph.py index 1b91ac6..c29dc99 100644 --- a/backend/pinscopex/graph.py +++ b/backend/pinscopex/graph.py @@ -269,6 +269,11 @@ def build_graph( # only tokenise correctly with the BOM's ref list as a lookup. EDIF # netlists ignore known_refs (designators are unambiguous tokens). 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( netlist_path, known_refs=set(bom.keys()), @@ -277,6 +282,10 @@ def build_graph( if fmt.startswith("kicad"): from backend.pinscopex.parsers_kicad import kicad_part_fields for ref, extra in kicad_part_fields(netlist_path).items(): + schematic_fields[ref] = { + "mpn": extra.get("mpn"), + "value": extra.get("value", ""), + } entry = bom.setdefault( ref, {"value": "", "footprint": "", "mpn": None, "lcsc": None, "datasheet_url": None}, @@ -397,4 +406,9 @@ def build_graph( pins=pin_connections, ) - return DesignGraph(components=components, nets=nets) + return DesignGraph( + components=components, + nets=nets, + bom_fields=bom_fields, + schematic_fields=schematic_fields, + ) diff --git a/backend/pinscopex/models.py b/backend/pinscopex/models.py index a78abd3..bcae6b5 100644 --- a/backend/pinscopex/models.py +++ b/backend/pinscopex/models.py @@ -244,6 +244,12 @@ class DesignGraph(BaseModel): """ components: dict[str, Component] = {} 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 -------------------------------------------------- @@ -330,6 +336,12 @@ class Finding(BaseModel): recommendation: str = "" reference: str = "" 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): @@ -420,3 +432,42 @@ class ResolvedPassive(BaseModel): power_rating: str | None = None dielectric: str | None = None 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] = [] + diff --git a/backend/pinscopex/parsers_kicad.py b/backend/pinscopex/parsers_kicad.py index c3d8809..01b9be9 100644 --- a/backend/pinscopex/parsers_kicad.py +++ b/backend/pinscopex/parsers_kicad.py @@ -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. -``.kicad_sch`` uses embedded ``lib_symbols`` plus wires/labels; hierarchical -sheets in other files are not followed (export a netlist for those). +``.kicad_sch`` uses embedded ``lib_symbols`` plus wires/labels. Hierarchical +``(sheet …)`` entries are followed from the root file (path-jailed under the +project directory). """ from __future__ import annotations @@ -10,6 +11,7 @@ from __future__ import annotations import math import re import xml.etree.ElementTree as ET +from dataclasses import dataclass, field from pathlib import Path from typing import Any, Iterator @@ -307,7 +309,30 @@ class _DSU: 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]]] = {} for sym in _kids(_kid(tree, "lib_symbols") or [], "symbol"): 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] = {} pin_at: dict[tuple[str, str], tuple[int, int]] = {} dsu = _DSU() - labels: dict[tuple[int, int], str] = {} + labels: dict[tuple[int, int], tuple[str, str]] = {} power_pts: list[tuple[tuple[int, int], 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 "" + 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"): lib_id = _val(sym, "lib_id") 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:]): dsu.union(a, b) return - if tag in {"label", "global_label", "hierarchical_label"}: + if tag == "label": name = str(node[1]) if len(node) > 1 else "" x, y, _ = _at(node) pt = _snap(x, y) dsu.add(pt) - if name: - labels[pt] = name + set_label(pt, name, "local") + 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 if tag == "junction": 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) - for pt, name in labels.items(): + for pt in labels: dsu.add(pt) for pt, _name in power_pts: 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] = {} - for pt, name in labels.items(): - root_name[dsu.find(pt)] = name + root_kind: dict[tuple[int, int], str] = {} + 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: - 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]]] = {} for (ref, pin), pt in pin_at.items(): 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() for root, pins in grouped.items(): name = root_name.get(root) + kind = root_kind.get(root, "unnamed") if not name: ref0, pin0 = pins[0] name = f"Net-({ref0}-Pad{pin0})" + kind = "unnamed" while name in used_names: name = name + "_" used_names.add(name) 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 @@ -461,7 +624,7 @@ def parse_kicad( tree = _parse_sexp(text) tag = _tag(tree) if tag == "kicad_sch": - parts, nets, fields = parse_kicad_sch(tree) + parts, nets, fields = parse_kicad_sch_project(p) elif tag == "export": parts, nets, fields = parse_kicad_sexp_netlist(tree) else: diff --git a/backend/pinscopex/parsers_kicad_pcb.py b/backend/pinscopex/parsers_kicad_pcb.py new file mode 100644 index 0000000..d29af30 --- /dev/null +++ b/backend/pinscopex/parsers_kicad_pcb.py @@ -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, + ) diff --git a/backend/pinscopex/passive_rail_check.py b/backend/pinscopex/passive_rail_check.py index 74f2ff4..73d9a9f 100644 --- a/backend/pinscopex/passive_rail_check.py +++ b/backend/pinscopex/passive_rail_check.py @@ -84,6 +84,9 @@ def check_supply_decoupling( f"near {ref}." ), reference="netlist topology", + net=net_name, + pins=[f"{ref}.{pin_num}"], + rule_id="PS-DEC-001", )) return findings @@ -133,6 +136,9 @@ def check_i2c_pullups( f"to the I2C I/O rail." ), reference="netlist topology", + net=net_name, + pins=[f"{ref}.{pin_num}"], + rule_id="PS-I2C-001", )) return findings @@ -184,6 +190,9 @@ def check_reset_pullups( f"from a reset supervisor / GPIO." ), reference="netlist topology", + net=net_name, + pins=[f"{ref}.{pin_num}"], + rule_id="PS-RST-001", )) return findings diff --git a/backend/pinscopex/pin_mux_check.py b/backend/pinscopex/pin_mux_check.py index f090db0..bb66f42 100644 --- a/backend/pinscopex/pin_mux_check.py +++ b/backend/pinscopex/pin_mux_check.py @@ -169,4 +169,7 @@ def _feasibility_finding( ), recommendation=rec, reference=f"{mpn or ref} alternate-function table", + net=net_name, + pins=[f"{ref}.{pin_num}"], + rule_id="PS-MUX-001", ) diff --git a/backend/routers/projects.py b/backend/routers/projects.py index edb3941..583989c 100644 --- a/backend/routers/projects.py +++ b/backend/routers/projects.py @@ -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( parts: dict[str, str], nets: dict[str, list[tuple[str, str]]], diff --git a/backend/services/dedupe_findings.py b/backend/services/dedupe_findings.py index da197f5..2ef9630 100644 --- a/backend/services/dedupe_findings.py +++ b/backend/services/dedupe_findings.py @@ -247,23 +247,16 @@ def _build_deduped( new_why = "Unverified: " + new_why try: - result.append(Finding( - finding_id=canon.finding_id, - designator=canon.designator, - mpn=canon.mpn, - aspect=canon.aspect, - finding=str(group.get("finding") or canon.finding), - 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( + result.append(canon.model_copy(update={ + "finding": str(group.get("finding") or canon.finding), + "why": new_why, + "source_page": group.get("source_page", canon.source_page), + "status": final_status, + "recommendation": str( group.get("recommendation") or canon.recommendation ), - reference=str(group.get("reference") or canon.reference), - source=canon.source, - )) + "reference": str(group.get("reference") or canon.reference), + })) except Exception: log.exception("dedupe: failed to build merged Finding") return None diff --git a/backend/services/normalize_findings.py b/backend/services/normalize_findings.py index 8e5ef12..e481ea6 100644 --- a/backend/services/normalize_findings.py +++ b/backend/services/normalize_findings.py @@ -394,20 +394,15 @@ def _build_normalized( new_why = "Unverified: " + new_why try: - result.append(Finding( - finding_id=canon.finding_id, - designator=canon.designator, - mpn=canon.mpn, - aspect=canon.aspect, - finding=str(entry.get("finding") or canon.finding), - why=new_why, - source_page=entry.get("source_page", canon.source_page), - 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), - )) + result.append(canon.model_copy(update={ + "finding": str(entry.get("finding") or canon.finding), + "why": new_why, + "source_page": entry.get("source_page", canon.source_page), + "source_quote": str(entry.get("source_quote") or canon.source_quote), + "status": final_status, + "recommendation": str(entry.get("recommendation") or canon.recommendation), + "reference": str(entry.get("reference") or canon.reference), + })) except Exception: log.exception("normalize: failed to build merged Finding") return None diff --git a/backend/services/pipeline.py b/backend/services/pipeline.py index 4bf56e6..c0c9219 100644 --- a/backend/services/pipeline.py +++ b/backend/services/pipeline.py @@ -1496,6 +1496,25 @@ async def _stage_passive_extraction(ctx: PipelineContext) -> None: {"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: """Stage 4 — Build the design graph from netlist, BOM, and extracted data.""" 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.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", {"stage": "graph_build", "status": "complete", @@ -2088,6 +2108,7 @@ async def run_regen_pipeline( graph_path = ws.local_path("design_graph.json") graph_path.write_text(graph.model_dump_json(indent=2) + "\n") + _write_layout_graph(ws, project_id) broker.publish(project_id, "step_update", {"stage": "graph_build", "status": "complete", diff --git a/backend/services/projects.py b/backend/services/projects.py index 68369b8..8c26f91 100644 --- a/backend/services/projects.py +++ b/backend/services/projects.py @@ -5,6 +5,7 @@ Each project lives at users/{user_id}/projects/{id}/ with: uploads/bom.csv — uploaded BOM uploads/netlist.asc — uploaded netlist uploads/datasheets/*.pdf — uploaded datasheets + uploads/pcb.kicad_pcb — optional KiCad board (layout checks) extracted/ — IC extraction output patterns/ — passive patterns models/ — cached component specs @@ -82,6 +83,7 @@ class ProjectMeta(BaseModel): updated: str = "" has_bom: bool = False has_netlist: bool = False + has_pcb: bool = False # "pads" | "edif" | None — None for legacy projects (pre-EDIF-support). # Legacy reads fall back to looking for netlist.asc on disk. netlist_format: str | None = None @@ -658,6 +660,15 @@ def save_netlist( 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( storage: StorageBackend, user_id: str, project_id: str, mpn: str, data: bytes ) -> str: diff --git a/backend/services/validation.py b/backend/services/validation.py index d2efcd3..694031a 100644 --- a/backend/services/validation.py +++ b/backend/services/validation.py @@ -49,6 +49,7 @@ from backend.pinscopex.passive_rail_check import ( check_reset_pullups, check_supply_decoupling, ) +from backend.pinscopex.bom_match_check import check_bom_schematic_match TRACE_VERSION = 1 @@ -70,6 +71,9 @@ def _run_deterministic_checks( ("supply_decoupling_check", lambda: check_supply_decoupling(graph, constraints_map)), ("i2c_pullup_check", lambda: check_i2c_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: out.extend(fn()) diff --git a/docs/piano-implementazione.md b/docs/piano-implementazione.md new file mode 100644 index 0000000..ce691e9 --- /dev/null +++ b/docs/piano-implementazione.md @@ -0,0 +1,510 @@ +# Piano di implementazione — Pinscope + +Documento di lavoro **prima dello sviluppo**. La lista dell’utente è 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 A–B, 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 all’utente (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 sull’editor cloud. Chi arriva da EasyEDA resta sul percorso già documentato (export PADS), senza lavoro nuovo. + +**Gerber** non è l’ingresso 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**. L’errore 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 6–10: `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 1–12 non si misurano e i plugin mentono. + +**E0. Eval harness.** Golden findings su `simple_project/` + 1–2 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 l’export. + +### 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 l’API 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 l’enable 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é l’eval 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**, l’utente 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 è sull’altro 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 | B3–B5 pull-up/decoupling/Cin-Cout | 0 | +| 3 | B6–B9 DC bias, ESR, filtri, termico | 2 | +| 4 | B1–B2 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 | D1–D2 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 A–B (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_.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 dall’LLM. +- Scraping errata senza whitelist. +- Layout da netlist PADS dump (`!PADS-POWERPCB`) — già rifiutato. + +--- + +## Prossimo passo concreto (quando si apre lo sviluppo) + +Sprint 0, in quest’ordine: + +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`. diff --git a/frontend/src/components/dashboard/create-project-dialog.tsx b/frontend/src/components/dashboard/create-project-dialog.tsx index eb918ce..9df2fa7 100644 --- a/frontend/src/components/dashboard/create-project-dialog.tsx +++ b/frontend/src/components/dashboard/create-project-dialog.tsx @@ -46,6 +46,7 @@ import { deleteProject, uploadBom, uploadNetlist, + uploadPcb, uploadDatasheet, startPipeline, checkLibrary, @@ -548,6 +549,7 @@ export function CreateProjectDialog({ const [name, setName] = useState(""); const [bomFile, setBomFile] = useState(null); const [netlistFile, setNetlistFile] = useState(null); + const [pcbFile, setPcbFile] = useState(null); const [netlistNetCount, setNetlistNetCount] = useState(null); const [netlistError, setNetlistError] = useState(null); @@ -1879,6 +1881,10 @@ export function CreateProjectDialog({ setProgress("Uploading netlist..."); await uploadNetlist(project.id, netlistFile!); } + if (pcbFile) { + setProgress("Uploading PCB..."); + await uploadPcb(project.id, pcbFile); + } } catch (uploadErr) { if (!projectAlreadyExists) { try { @@ -2112,6 +2118,19 @@ export function CreateProjectDialog({ )} +
+ setPcbFile(files[0] ?? null)} + /> +

+ Optional. Layout checks (trace width, 3W, placement mm) stay + off until a board is present. Schema review still runs + without it. +

+
)} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 7d8abc2..c58a530 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -61,6 +61,7 @@ function mapProject(p: Record): Project { summary: p.summary as Record | undefined, hasNetlist: p.has_netlist as boolean, hasBom: p.has_bom as boolean, + hasPcb: (p.has_pcb as boolean | undefined) ?? false, datasheetCount: p.datasheet_count as number, skippedComponents: (p.skipped_components as SkippedComponent[] | null) ?? undefined, completedReviewRefs: (p.completed_review_refs as string[] | null) ?? undefined, @@ -277,6 +278,22 @@ export interface UploadNetlistResult { 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( projectId: string, file: File, ): Promise { diff --git a/frontend/src/lib/report-export.ts b/frontend/src/lib/report-export.ts index f85beea..292fe78 100644 --- a/frontend/src/lib/report-export.ts +++ b/frontend/src/lib/report-export.ts @@ -12,10 +12,13 @@ const HEADER = [ "Description", "Recommendation", "Source", + "Net", + "Pins", + "Rule", ]; // 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 // finding card's reference button + "Automated check" tag logic. @@ -47,6 +50,9 @@ export function exportReportToExcel( f.why ?? "", f.recommendation ?? "", formatSource(f), + f.net ?? "", + (f.pins ?? []).join(", "), + f.rule_id ?? "", ]); const ws = XLSX.utils.aoa_to_sheet([HEADER, ...rows]); diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index f98e801..0a354ac 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -14,6 +14,12 @@ export interface Finding { recommendation?: 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 + net?: string | null; + pins?: string[]; + rule_id?: string | null; + cad_sheet?: string | null; + cad_uuid?: string | null; + variant?: string | null; } export interface FindingComment { @@ -88,6 +94,8 @@ export interface Net { export interface DesignGraph { components: Record; nets: Record; + bom_fields?: Record; + schematic_fields?: Record; } export interface BomSummaryRow { @@ -226,6 +234,7 @@ export interface Project { summary?: Record; hasNetlist: boolean; hasBom: boolean; + hasPcb?: boolean; datasheetCount: number; skippedComponents?: SkippedComponent[]; completedReviewRefs?: string[]; diff --git a/simple_project/eval_golden.json b/simple_project/eval_golden.json new file mode 100644 index 0000000..de85944 --- /dev/null +++ b/simple_project/eval_golden.json @@ -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" + ] +} diff --git a/tests/test_bom_match_check.py b/tests/test_bom_match_check.py new file mode 100644 index 0000000..8f47ae3 --- /dev/null +++ b/tests/test_bom_match_check.py @@ -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( + """ + + + + LDO + SPX3819M5-L-3-3 + + + + + + +""" + ) + 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" diff --git a/tests/test_dedupe_findings.py b/tests/test_dedupe_findings.py index 4f8c1a1..ae28212 100644 --- a/tests/test_dedupe_findings.py +++ b/tests/test_dedupe_findings.py @@ -65,6 +65,30 @@ def test_singletons_pass_through_unchanged(): 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(): """U2-001 + U3-001 → one finding. primary_index picks which side supplies the canonical designator + datasheet citation.""" diff --git a/tests/test_eval_report.py b/tests/test_eval_report.py new file mode 100644 index 0000000..dfbbf50 --- /dev/null +++ b/tests/test_eval_report.py @@ -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) diff --git a/tests/test_finding_schema.py b/tests/test_finding_schema.py new file mode 100644 index 0000000..b269b0d --- /dev/null +++ b/tests/test_finding_schema.py @@ -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") diff --git a/tests/test_kicad_parser.py b/tests/test_kicad_parser.py index cffc6ad..2c9ea25 100644 --- a/tests/test_kicad_parser.py +++ b/tests/test_kicad_parser.py @@ -114,3 +114,225 @@ def test_kicad_mpn_fills_empty_bom(tmp_path: Path): ) assert g.components["U1"].mpn == "MSPM0G3507SPTR" 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) diff --git a/tests/test_kicad_pcb.py b/tests/test_kicad_pcb.py new file mode 100644 index 0000000..8222121 --- /dev/null +++ b/tests/test_kicad_pcb.py @@ -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"] diff --git a/tests/test_normalize_findings.py b/tests/test_normalize_findings.py index 087ff45..098417a 100644 --- a/tests/test_normalize_findings.py +++ b/tests/test_normalize_findings.py @@ -50,6 +50,36 @@ def test_serialize_for_prompt_shows_reviewer_severity(): 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(): props = SUBMIT_NORMALIZED_SCHEMA.input_schema["properties"] assert "dropped" in props diff --git a/tests/test_passive_rail_check.py b/tests/test_passive_rail_check.py index 2f4fdc3..b57d303 100644 --- a/tests/test_passive_rail_check.py +++ b/tests/test_passive_rail_check.py @@ -104,6 +104,8 @@ def test_i2c_missing_pullup(): findings = check_i2c_pullups(g, cons) assert len(findings) == 1 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(): diff --git a/tests/test_pin_mux_check.py b/tests/test_pin_mux_check.py index ec534ff..7089c99 100644 --- a/tests/test_pin_mux_check.py +++ b/tests/test_pin_mux_check.py @@ -69,6 +69,9 @@ def test_real_defect_uart5_swapped_is_error(): assert {f.designator for f in findings} == {"U3"} 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 tx.rule_id == "PS-MUX-001" + assert tx.net == "MCU-UART5-TX" + assert tx.pins == ["U3.54"] def test_correct_assignment_no_finding():