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:
@@ -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
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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] = []
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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]]],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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_<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 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`.
|
||||
@@ -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<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 [netlistError, setNetlistError] = useState<string | null>(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({
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -61,6 +61,7 @@ function mapProject(p: Record<string, unknown>): Project {
|
||||
summary: p.summary as Record<string, number> | 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<UploadNetlistResult> {
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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<string, Component>;
|
||||
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 {
|
||||
@@ -226,6 +234,7 @@ export interface Project {
|
||||
summary?: Record<string, number>;
|
||||
hasNetlist: boolean;
|
||||
hasBom: boolean;
|
||||
hasPcb?: boolean;
|
||||
datasheetCount: number;
|
||||
skippedComponents?: SkippedComponent[];
|
||||
completedReviewRefs?: string[];
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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."""
|
||||
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user