diff --git a/backend/pinscopex/crystal_cl_check.py b/backend/pinscopex/crystal_cl_check.py new file mode 100644 index 0000000..cb6252e --- /dev/null +++ b/backend/pinscopex/crystal_cl_check.py @@ -0,0 +1,163 @@ +"""Crystal load capacitance vs load caps — numbers only when present. + +CL_eff ≈ (C1·C2)/(C1+C2) + Cstray. Cstray used only if specs list it; +never invent a stray default. Without CL in specs → skip. +""" + +from __future__ import annotations + +from backend.pinscopex.functional_groups import ( + _cap_farads, + _is_ground_net, + load_capacitance_farads, +) +from backend.pinscopex.models import ( + Component, + ComponentType, + DesignGraph, + Finding, + SimpleComponentSpecs, +) + +_STRAY_KEYS = ("stray_capacitance_f", "board_stray_f", "cstray_f") + + +def check_crystal_cl(graph: DesignGraph) -> list[Finding]: + findings: list[Finding] = [] + for ref, comp in sorted(graph.components.items()): + if comp.component_type != ComponentType.CRYSTAL: + continue + cl = load_capacitance_farads(comp) + if cl is None: + continue + load_caps = _load_caps_for_crystal(graph, comp) + if len(load_caps) < 2: + findings.append(Finding( + designator=ref, + mpn=comp.mpn or "", + aspect="clock", + source="crystal_cl_check", + status="WARNING", + finding=( + f"{ref} specifies CL={_fmt_f(cl)} but fewer than two load " + f"capacitors were found on its non-ground nets " + f"({[c.reference for c in load_caps] or 'none'})." + ), + why="Crystal load capacitance needs a matched C1/C2 pair.", + recommendation="Add or value the two load capacitors on XIN/XOUT.", + reference="netlist topology", + rule_id="PS-XTAL-001", + pins=[ref], + )) + continue + + # Use the two caps with known farads closest to equal (typical C1≈C2). + valued = [(c, _cap_farads(c)) for c in load_caps] + known = [(c, f) for c, f in valued if f is not None] + if len(known) < 2: + continue + known.sort(key=lambda x: x[1]) + # Prefer a pair with similar values: take the two largest known if many. + c1, f1 = known[-2] + c2, f2 = known[-1] + series = (f1 * f2) / (f1 + f2) if (f1 + f2) > 0 else None + if series is None: + continue + stray = _stray_farads(comp) + c_eff = series + (stray or 0.0) + + if stray is None: + # Without stray: only flag when series alone already exceeds CL. + if series > cl * 1.25: + findings.append(Finding( + designator=ref, + mpn=comp.mpn or "", + aspect="clock", + source="crystal_cl_check", + status="WARNING", + finding=( + f"{ref} CL={_fmt_f(cl)}; C1={c1.reference} {_fmt_f(f1)} and " + f"C2={c2.reference} {_fmt_f(f2)} give series≈{_fmt_f(series)} " + f"(already above CL; board stray not in specs)." + ), + why="Series combination of load caps exceeds specified CL without needing stray.", + recommendation="Reduce load caps or confirm the datasheet CL value.", + reference="netlist topology", + rule_id="PS-XTAL-002", + pins=[ref, c1.reference, c2.reference], + )) + elif series < cl * 0.5: + findings.append(Finding( + designator=ref, + mpn=comp.mpn or "", + aspect="clock", + source="crystal_cl_check", + status="INFO", + finding=( + f"{ref} CL={_fmt_f(cl)}; series of {c1.reference}/{c2.reference} " + f"≈{_fmt_f(series)} (stray unknown — verify against datasheet)." + ), + why="Without stray capacitance in specs, effective CL cannot be fully checked.", + recommendation="Confirm Cstray or populate load_capacitance / stray in crystal specs.", + reference="netlist topology", + rule_id="PS-XTAL-003", + pins=[ref, c1.reference, c2.reference], + )) + continue + + if c_eff > cl * 1.25 or c_eff < cl * 0.75: + findings.append(Finding( + designator=ref, + mpn=comp.mpn or "", + aspect="clock", + source="crystal_cl_check", + status="WARNING", + finding=( + f"{ref} CL={_fmt_f(cl)}; C_eff≈{_fmt_f(c_eff)} " + f"(series {_fmt_f(series)} + stray {_fmt_f(stray)}) " + f"from {c1.reference}/{c2.reference}." + ), + why="Effective load capacitance should stay near the crystal's specified CL.", + recommendation="Adjust C1/C2 so C_eff ≈ CL.", + reference="netlist topology", + rule_id="PS-XTAL-002", + pins=[ref, c1.reference, c2.reference], + )) + return findings + + +def _load_caps_for_crystal(graph: DesignGraph, crystal: Component) -> list[Component]: + caps: dict[str, Component] = {} + for net in crystal.pins.values(): + if not net or _is_ground_net(graph, net): + continue + for cref in graph.capacitors_on_net(net): + cap = graph.components.get(cref) + if cap: + caps[cref] = cap + return list(caps.values()) + + +def _stray_farads(comp: Component) -> float | None: + specs = comp.specs + if not isinstance(specs, SimpleComponentSpecs): + return None + for key in _STRAY_KEYS: + raw = specs.values.get(key) + if raw is None: + continue + try: + v = float(raw) + except (TypeError, ValueError): + continue + if v >= 0: + return v + return None + + +def _fmt_f(farads: float) -> str: + if farads >= 1e-6: + return f"{farads * 1e6:.3g}µF" + if farads >= 1e-9: + return f"{farads * 1e9:.3g}nF" + return f"{farads * 1e12:.3g}pF" diff --git a/backend/pinscopex/functional_groups.py b/backend/pinscopex/functional_groups.py new file mode 100644 index 0000000..870ecb7 --- /dev/null +++ b/backend/pinscopex/functional_groups.py @@ -0,0 +1,439 @@ +"""Topology-only functional groups for Layout F1 (routing-first floorplan). + +No millimetres. Domains = power-net islands; satellites = 1-hop neighbors +classified with role_hint; layout_rules attached from IC extraction when present. + +Self-contained helpers (no import of ``validate`` / Anthropic). +""" + +from __future__ import annotations + +import re +from typing import Any, Literal + +from pydantic import BaseModel + +from backend.pinscopex.models import ( + CapacitorSpecs, + Component, + ComponentConstraints, + ComponentType, + DesignGraph, + NetType, + SimpleComponentSpecs, +) +from backend.pinscopex.resolve_passives import _parse_spice_value + +RoleHint = Literal[ + "decoupling", + "bulk", + "load_cap", + "filter", + "pullup", + "series", + "divider", + "bridge", + "crystal", + "other", +] + +_BULK_F = 1e-6 # >= 1 µF → bulk candidate +_XTAL_RE = re.compile( + r"(?:^|[_/])(X(?:IN|OUT)|XTAL|OSC|HFX(?:IN|OUT)|LFX(?:IN|OUT)|CLK(?:IN|OUT)?)(?:$|[_/\d])", + re.I, +) +_SUPPLY_PIN_RE = re.compile( + r"(?:^|[_/])(VDD|VCC|VDDA|VDDD|VDDIO|DVDD|AVDD|IOVDD|VDD33|VDD18|" + r"VIN|VBAT|VBUS|VCORE)(?:$|[_/\d])", + re.IGNORECASE, +) +_RAIL_PIN_RE = re.compile(r"^(?:\+?\d+V\d*)$", re.IGNORECASE) +_NOT_SUPPLY_RE = re.compile( + r"\b(VSS|GND|VEE|VOUT|VREF|SW|LX|FB|BOOT|NC|VPP)\b", + re.IGNORECASE, +) +_RANK_PREFIXES: list[tuple[str, int]] = [ + ("ic.mcu", 0), + ("ic.mpu", 0), + ("ic.fpga", 0), + ("ic.soc", 0), + ("ic.power", 1), + ("ic.interface", 2), + ("ic.protection", 3), + ("ic.", 4), +] + + +class PlacementSatellite(BaseModel): + ref: str + component_type: str + component_subtype: str | None = None + nets: list[str] = [] + hop: int = 1 + role_hint: RoleHint = "other" + + +class PlacementIcGroup(BaseModel): + ref: str + mpn: str | None = None + component_subtype: str | None = None + rank: int = 99 + nets: list[str] = [] + satellites: list[PlacementSatellite] = [] + layout_rules: list[dict[str, Any]] = [] + assemble_order: list[str] = [] + + +class PlacementDomain(BaseModel): + domain_id: str + power_nets: list[str] = [] + ic_refs: list[str] = [] + assemble_order: list[str] = [] + + +class FunctionalGroupsReport(BaseModel): + """Routing-first placement topology (no coordinates).""" + objective: Literal["routing"] = "routing" + domains: list[PlacementDomain] = [] + groups: list[PlacementIcGroup] = [] + + +def build_functional_groups( + graph: DesignGraph, + constraints_map: dict[str, ComponentConstraints] | None = None, +) -> FunctionalGroupsReport: + """Build domains + per-IC satellite groups from the design graph.""" + cmap = constraints_map or {} + ic_refs = [ + r for r, c in graph.components.items() + if c.component_type == ComponentType.IC + ] + groups: list[PlacementIcGroup] = [] + for ref in sorted(ic_refs, key=lambda r: (_ic_rank(graph.components[r]), r)): + groups.append(_group_for_ic(graph, ref, cmap)) + + domains = _build_domains(graph, ic_refs) + by_ref = {g.ref: g for g in groups} + for dom in domains: + order: list[str] = [] + ranked = sorted( + dom.ic_refs, + key=lambda r: (by_ref[r].rank if r in by_ref else 99, r), + ) + for iref in ranked: + order.append(iref) + g = by_ref.get(iref) + if g: + for sat in g.satellites: + if sat.ref not in order: + order.append(sat.ref) + dom.assemble_order = order + + return FunctionalGroupsReport(objective="routing", domains=domains, groups=groups) + + +def load_capacitance_farads(comp: Component) -> float | None: + """Crystal CL from SimpleComponentSpecs.values, if present.""" + specs = comp.specs + if not isinstance(specs, SimpleComponentSpecs): + return None + raw = specs.values.get("load_capacitance_f") + if raw is None: + return None + try: + v = float(raw) + except (TypeError, ValueError): + return None + return v if v > 0 else None + + +def _match_constraints( + mpn: str | None, + datasheets: dict[str, ComponentConstraints], +) -> ComponentConstraints | None: + if not mpn: + return None + if mpn in datasheets: + return datasheets[mpn] + norm = re.sub(r"[/_\-\s]", "", mpn).upper() + for ds_mpn, constraints in datasheets.items(): + if re.sub(r"[/_\-\s]", "", ds_mpn).upper() == norm: + return constraints + return None + + +def _ic_rank(comp: Component) -> int: + sub = (comp.component_subtype or "").lower() + for prefix, rank in _RANK_PREFIXES: + if sub == prefix.rstrip(".") or sub.startswith(prefix): + return rank + return 9 + + +def _group_for_ic( + graph: DesignGraph, + ref: str, + cmap: dict[str, ComponentConstraints], +) -> PlacementIcGroup: + comp = graph.components[ref] + cons = _match_constraints(comp.mpn or comp.value, cmap) + nets = [n for n in graph.nets_of_component(ref) if not _is_ground_net(graph, n)] + sat_map: dict[str, PlacementSatellite] = {} + + for net_name, others in graph.neighbors(ref).items(): + if _is_ground_net(graph, net_name): + continue + for oref in others: + if oref == ref or oref in sat_map: + continue + other = graph.components.get(oref) + if not other or other.component_type == ComponentType.IC: + continue + role = _role_hint(graph, comp, cons, other, net_name) + sat_map[oref] = PlacementSatellite( + ref=oref, + component_type=other.component_type.value, + component_subtype=other.component_subtype, + nets=sorted({n for n in other.pins.values() if n}), + hop=1, + role_hint=role, + ) + + for pin_num, net_name in comp.pins.items(): + if not net_name or _is_ground_net(graph, net_name): + continue + if not _is_ic_supply_pin(graph, cons, pin_num, net_name): + continue + for cref in graph.capacitors_on_net(net_name): + if cref == ref: + continue + cap = graph.components.get(cref) + if not cap: + continue + others = {n for n in cap.pins.values() if n != net_name} + if not any(_is_ground_net(graph, n) for n in others): + continue + farads = _cap_farads(cap) + role: RoleHint = "bulk" if farads is not None and farads >= _BULK_F else "decoupling" + existing = sat_map.get(cref) + if existing is None or existing.role_hint in ("other", "series"): + sat_map[cref] = PlacementSatellite( + ref=cref, + component_type=cap.component_type.value, + component_subtype=cap.component_subtype, + nets=sorted({n for n in cap.pins.values() if n}), + hop=1, + role_hint=role, + ) + + satellites = sorted(sat_map.values(), key=lambda s: (_role_sort(s.role_hint), s.ref)) + assemble = [ref] + [s.ref for s in satellites] + rules: list[dict[str, Any]] = list(cons.layout_rules) if cons and cons.layout_rules else [] + + return PlacementIcGroup( + ref=ref, + mpn=comp.mpn, + component_subtype=comp.component_subtype or (cons.component_subtype if cons else None), + rank=_ic_rank(comp), + nets=sorted(nets), + satellites=satellites, + layout_rules=rules, + assemble_order=assemble, + ) + + +def _role_sort(role: RoleHint) -> int: + order = [ + "decoupling", "bulk", "load_cap", "crystal", "filter", + "pullup", "divider", "series", "bridge", "other", + ] + try: + return order.index(role) + except ValueError: + return 99 + + +def _role_hint( + graph: DesignGraph, + ic: Component, + cons: ComponentConstraints | None, + other: Component, + via_net: str, +) -> RoleHint: + if other.component_type == ComponentType.CRYSTAL: + return "crystal" + + if other.component_type == ComponentType.CAPACITOR: + if _looks_xtal_net(via_net) or _ic_pin_is_xtal(cons, via_net, ic): + return "load_cap" + others = {n for n in other.pins.values() if n != via_net} + if any(_is_ground_net(graph, n) for n in others) and ( + _is_power_net(graph, via_net) or _net_is_ic_supply(graph, ic, cons, via_net) + ): + farads = _cap_farads(other) + return "bulk" if farads is not None and farads >= _BULK_F else "decoupling" + return "other" + + if other.component_type == ComponentType.INDUCTOR: + return "filter" + + if other.component_type == ComponentType.RESISTOR: + nets = list(dict.fromkeys(other.pins.values())) + if len(nets) == 2: + a, b = nets + if _is_power_net(graph, a) or _is_power_net(graph, b): + if _is_ground_net(graph, a) or _is_ground_net(graph, b): + return "divider" + return "pullup" + ic_nets = set(ic.pins.values()) + if a in ic_nets and b in ic_nets: + return "bridge" + if a in ic_nets or b in ic_nets: + return "series" + return "other" + + return "other" + + +def _looks_xtal_net(name: str) -> bool: + return bool(_XTAL_RE.search(name or "")) + + +def _ic_pin_is_xtal( + cons: ComponentConstraints | None, + net_name: str, + ic: Component, +) -> bool: + for pin_num, n in ic.pins.items(): + if n != net_name: + continue + tokens = _pin_name_tokens(cons, pin_num) + if any(_XTAL_RE.search(t) for t in tokens): + return True + return _looks_xtal_net(net_name) + + +def _net_is_ic_supply( + graph: DesignGraph, + ic: Component, + cons: ComponentConstraints | None, + net_name: str, +) -> bool: + for pin_num, n in ic.pins.items(): + if n == net_name and _is_ic_supply_pin(graph, cons, pin_num, net_name): + return True + return _is_power_net(graph, net_name) + + +def _build_domains(graph: DesignGraph, ic_refs: list[str]) -> list[PlacementDomain]: + parent = {r: r for r in ic_refs} + + def find(x: str) -> str: + while parent[x] != x: + parent[x] = parent[parent[x]] + x = parent[x] + return x + + def union(a: str, b: str) -> None: + ra, rb = find(a), find(b) + if ra != rb: + parent[rb] = ra + + power_by_ic: dict[str, set[str]] = {} + for ref in ic_refs: + nets = set() + for n in graph.nets_of_component(ref): + if _is_power_net(graph, n) and not _is_ground_net(graph, n): + nets.add(n) + power_by_ic[ref] = nets + + rail_owners: dict[str, list[str]] = {} + for ref, nets in power_by_ic.items(): + for n in nets: + rail_owners.setdefault(n, []).append(ref) + for refs in rail_owners.values(): + for i in range(1, len(refs)): + union(refs[0], refs[i]) + + clusters: dict[str, list[str]] = {} + for ref in ic_refs: + clusters.setdefault(find(ref), []).append(ref) + + domains: list[PlacementDomain] = [] + for i, (_root, members) in enumerate( + sorted(clusters.items(), key=lambda x: sorted(x[1])[0]), + ): + members_sorted = sorted(members) + rails: set[str] = set() + for m in members_sorted: + rails |= power_by_ic.get(m, set()) + domains.append(PlacementDomain( + domain_id=f"domain_{i + 1}", + power_nets=sorted(rails), + ic_refs=members_sorted, + )) + return domains + + +def _pin_name_tokens(cons: ComponentConstraints | None, pin_num: str) -> list[str]: + if not cons: + return [] + pin = cons.pin_by_number(pin_num) + if not pin or not pin.name: + return [] + return [t.strip() for t in re.split(r"[/,]", pin.name) if t.strip()] + + +def _looks_like_supply(text: str) -> bool: + t = (text or "").strip() + if not t: + return False + if _NOT_SUPPLY_RE.search(t) and not _SUPPLY_PIN_RE.search(t): + return False + return bool(_SUPPLY_PIN_RE.search(t) or _RAIL_PIN_RE.match(t)) + + +def _is_ic_supply_pin( + graph: DesignGraph, + cons: ComponentConstraints | None, + pin_num: str, + net_name: str, +) -> bool: + tokens = _pin_name_tokens(cons, pin_num) + if tokens: + return any(_looks_like_supply(t) for t in tokens) + if _looks_like_supply(net_name or ""): + return True + net = graph.nets.get(net_name) + return bool(net and net.net_type == NetType.POWER) + + +def _is_ground_net(graph: DesignGraph, name: str) -> bool: + net = graph.nets.get(name) + if net and net.net_type == NetType.GROUND: + return True + u = name.upper().replace("-", "_") + return u in ("GND", "VSS", "AGND", "DGND", "PGND", "GNDA", "GNDD") or ( + u.startswith("GND") or u.endswith("_GND") or u.endswith("_VSS") + ) + + +def _is_power_net(graph: DesignGraph, name: str) -> bool: + net = graph.nets.get(name) + if net and net.net_type == NetType.POWER: + return True + return bool(re.match(r"^\+?\d+V\d*", (name or "").upper())) + + +def _cap_farads(comp: Component) -> float | None: + specs = comp.specs + if isinstance(specs, CapacitorSpecs) and specs.value_farads > 0: + return float(specs.value_farads) + raw = (comp.value or "").strip() + if not raw: + return None + try: + v = _parse_spice_value(raw) + except ValueError: + return None + return v if v > 0 else None diff --git a/backend/pinscopex/nc_pin_check.py b/backend/pinscopex/nc_pin_check.py new file mode 100644 index 0000000..f73d02f --- /dev/null +++ b/backend/pinscopex/nc_pin_check.py @@ -0,0 +1,109 @@ +"""NC pintable pins must not sit on an active net with other parts.""" + +from __future__ import annotations + +import re + +from backend.pinscopex.models import ( + ComponentConstraints, + ComponentType, + DesignGraph, + Finding, +) + +_NC_NAME_RE = re.compile( + r"^(?:n/?c|n\.c\.|nc|unconnected|no[_-]?connect|not[_-]?connected)$", + re.IGNORECASE, +) +_NC_NET_RE = re.compile( + r"^(?:n/?c|n\.c\.|nc|unconnected|no[_-]?connect|not[_-]?connected)$", + re.IGNORECASE, +) + + +def check_nc_pins( + graph: DesignGraph, + constraints_map: dict[str, ComponentConstraints], +) -> list[Finding]: + findings: list[Finding] = [] + for ref, comp in sorted(graph.components.items()): + if comp.component_type != ComponentType.IC: + continue + cons = _match(comp.mpn or comp.value, constraints_map) + if not cons or not cons.pintable: + continue + for pin in cons.pintable: + if not _is_nc_pin_name(pin.name or ""): + continue + net_name = comp.pins.get(str(pin.number)) + if not net_name: + continue + if _NC_NET_RE.match(net_name.strip()): + continue + others = [ + r for r in graph.components_on_net(net_name) + if r != ref + ] + if not others: + # Lone net named oddly but empty of other parts — still flag if + # the net name looks like a real signal (not floating placeholder). + if _looks_active_net(net_name): + findings.append(_finding(ref, comp.mpn or "", pin.number, pin.name, net_name, [])) + continue + findings.append(_finding(ref, comp.mpn or "", pin.number, pin.name, net_name, others)) + return findings + + +def _finding(ref, mpn, pin_num, pin_name, net, others) -> Finding: + other_s = ", ".join(others[:6]) if others else "(no other refs)" + return Finding( + designator=ref, + mpn=mpn, + aspect="connectivity", + source="nc_pin_check", + status="WARNING", + finding=( + f"{ref} pin {pin_num} ({pin_name or 'NC'}) is marked NC in the " + f"pintable but connects to net '{net}'" + + (f" with {other_s}." if others else ".") + ), + why="No-connect pins should remain unconnected or on an explicit NC net.", + recommendation="Leave the NC pin floating or disconnect the net.", + reference="pintable", + rule_id="PS-NC-001", + net=net, + pins=[f"{ref}.{pin_num}"], + ) + + +def _is_nc_pin_name(name: str) -> bool: + t = (name or "").strip() + if not t: + return False + if _NC_NAME_RE.match(t): + return True + # Slash-separated alts: "NC/GPIO" still counts as NC-capable; only pure NC. + parts = [p.strip() for p in re.split(r"[/,]", t) if p.strip()] + return bool(parts) and all(_NC_NAME_RE.match(p) or p.upper() == "NC" for p in parts) + + +def _looks_active_net(name: str) -> bool: + u = (name or "").strip() + if not u or u.startswith("unconnected"): + return False + return not _NC_NET_RE.match(u) + + +def _match( + mpn: str | None, + datasheets: dict[str, ComponentConstraints], +) -> ComponentConstraints | None: + if not mpn: + return None + if mpn in datasheets: + return datasheets[mpn] + norm = re.sub(r"[/_\-\s]", "", mpn).upper() + for ds_mpn, constraints in datasheets.items(): + if re.sub(r"[/_\-\s]", "", ds_mpn).upper() == norm: + return constraints + return None diff --git a/backend/services/pipeline.py b/backend/services/pipeline.py index c865fc4..da3a9fc 100644 --- a/backend/services/pipeline.py +++ b/backend/services/pipeline.py @@ -1538,6 +1538,24 @@ def _write_layout_graph(ws: PipelineWorkspace, project_id: str) -> None: logger.exception("kicad_pcb parse failed — continuing without layout") +def _write_functional_groups(ws: PipelineWorkspace, graph) -> None: + """Layout F1: topology domains/groups (no mm). Fail-soft.""" + try: + from backend.pinscopex.functional_groups import build_functional_groups + from backend.pinscopex.validate import _build_constraints_map, _load_datasheets + + extracted_dir = ws.local_path("extracted") + cmap = {} + if extracted_dir.is_dir(): + cmap = _build_constraints_map(_load_datasheets(extracted_dir)) + report = build_functional_groups(graph, cmap) + out = ws.local_path("functional_groups.json") + out.write_text(report.model_dump_json(indent=2) + "\n") + ws._upload_file("functional_groups.json") + except Exception: + logger.exception("functional_groups.json write failed — continuing") + + def _write_impedance_nets(ws: PipelineWorkspace, graph) -> None: """ImpedenceFinder Z0 on routed signal nets. Skip without PCB stackup.""" path = ws.local_path("layout_graph.json") @@ -1592,6 +1610,7 @@ async def _stage_graph_build(ctx: PipelineContext) -> None: graph_path.write_text(ctx.graph.model_dump_json(indent=2) + "\n") _write_layout_graph(ctx.ws, ctx.project_id) _write_impedance_nets(ctx.ws, ctx.graph) + _write_functional_groups(ctx.ws, ctx.graph) broker.publish(ctx.project_id, "step_update", {"stage": "graph_build", "status": "complete", @@ -2158,6 +2177,7 @@ async def run_regen_pipeline( graph_path.write_text(graph.model_dump_json(indent=2) + "\n") _write_layout_graph(ws, project_id) _write_impedance_nets(ws, graph) + _write_functional_groups(ws, graph) broker.publish(project_id, "step_update", {"stage": "graph_build", "status": "complete", diff --git a/backend/services/validation.py b/backend/services/validation.py index 5113610..cbe25ce 100644 --- a/backend/services/validation.py +++ b/backend/services/validation.py @@ -63,6 +63,8 @@ from backend.pinscopex.errata_check import check_errata from backend.pinscopex.internal_features_check import check_internal_features from backend.pinscopex.placement_check import check_placement from backend.pinscopex.si_check import check_si +from backend.pinscopex.crystal_cl_check import check_crystal_cl +from backend.pinscopex.nc_pin_check import check_nc_pins TRACE_VERSION = 1 @@ -100,6 +102,8 @@ def _run_deterministic_checks( ("internal_features_check", lambda: check_internal_features(graph, constraints_map)), ("placement_check", lambda: check_placement(graph, constraints_map, layout)), ("si_check", lambda: check_si(graph, constraints_map, layout)), + ("crystal_cl_check", lambda: check_crystal_cl(graph)), + ("nc_pin_check", lambda: check_nc_pins(graph, constraints_map)), ): try: out.extend(fn()) diff --git a/docs/piano-implementazione.md b/docs/piano-implementazione.md index 2b8f77c..7ffdc35 100644 --- a/docs/piano-implementazione.md +++ b/docs/piano-implementazione.md @@ -20,11 +20,29 @@ Fonte originale: canvas *Pinscope: crescita e DeepSeek*. Qui lo stato operativo. | P1 | Thinking vs tools | Partial — review auto fino ultimo turno | | P2 | Eval, fingerprint, KiCad GA, PCB nets | **Done** | | P2 | `shortest_path` tool + library write gate | **Done** | -| P2 | Crystal CL / abs-max numerici | Todo | +| P2 | Crystal CL + NC pin | **Done** — check deterministici (numeri solo se presenti) | | P3 | Plugin CI / chat report | Todo | -| Layout | Placement IC (mm) | **Dopo** — prodotto Layout, non questo sprint | +| Layout F1 | Domini / gruppi / satelliti | **Done** — `functional_groups.json` (no mm) | +| Layout F2 | Placement IC packing mm | **Dopo** — gated `.kicad_pcb` + `layout_rules` numerici | -**Done when (prossimo pacchetto):** smoke `--live` verde; hit_ratio visibile in UI logs; 1–2 check crystal/NC nuovi. +**Done when (prossimo pacchetto):** smoke `--live` verde; hit_ratio visibile in UI logs; pipeline Placement parallela (API dedicata). + +--- + +## 0c. Placement / floorplan (routing-first) + +Obiettivo unico: **routing migliore** (loop corti, meno crossing, canali liberi) — non bellezza dei footprint. + +| Step | Cosa | Artefatto / stato | +| --- | --- | --- | +| 1 | Chip chiave (`ComponentType.IC`, rank per subtype) | F1 | +| 2 | Domini = isole sulle **power nets** (non geometria) | F1 | +| 3 | Gruppi minori: decoupling, bulk, load_cap, filter, pullup, … | F1 `role_hint` | +| 4 | `layout_rules` già estratti sull’IC (nessun mm inventato) | F1 attach | +| 5 | `assemble_order` dominio → chip → gruppi (contratto packer) | F1 metadato | +| 6 | Packing mm / zone PCB / export | **F2** | + +Output F1: `functional_groups.json` scritto in `graph_build`. Verifica PCB esistente resta `placement_check` (PS-PLC*) — non confondere con packing. --- diff --git a/frontend/content/changelog.md b/frontend/content/changelog.md index 74745a8..fa31b28 100644 --- a/frontend/content/changelog.md +++ b/frontend/content/changelog.md @@ -2,6 +2,14 @@ What's new in Pinscope. +## 2.28.0 — 2026-09-12 — Layout F1 topology + crystal/NC checks + +Routing-first floorplan foundation without inventing millimetres: domains and satellite role hints after graph build, plus deterministic crystal CL and NC-pin checks. + +- [New] `functional_groups.json` from `graph_build` (domains, IC groups, `role_hint`, attached `layout_rules`). +- [New] `crystal_cl_check` (PS-XTAL-*) and `nc_pin_check` (PS-NC-001) in the deterministic suite. +- [Docs] Piano §0c Placement F1/F2; packing mm stays Layout F2. + ## 2.27.1 — 2026-09-12 — DeepSeek roadmap integrations Close the open P0/P2 items from the growth plan: offline smoke on `simple_project`, reviewer `shortest_path`, library promotion gate, PDF drop logging, per-stage cache hit-rate helper. IC placement stays Layout-product backlog. diff --git a/tests/test_crystal_cl_check.py b/tests/test_crystal_cl_check.py new file mode 100644 index 0000000..a6a6992 --- /dev/null +++ b/tests/test_crystal_cl_check.py @@ -0,0 +1,97 @@ +"""Crystal CL check — only fires when CL and cap values are known.""" + +from __future__ import annotations + +from backend.pinscopex.crystal_cl_check import check_crystal_cl +from backend.pinscopex.models import ( + Component, + ComponentType, + DesignGraph, + Net, + NetType, + PinConnection, + SimpleComponentSpecs, +) + + +def _xtal_graph(*, cl_f: float | None, c1: str, c2: str, stray: float | None = None) -> DesignGraph: + values: dict = {} + if cl_f is not None: + values["load_capacitance_f"] = cl_f + if stray is not None: + values["stray_capacitance_f"] = stray + return DesignGraph( + components={ + "X1": Component( + reference="X1", + value="8MHz", + footprint="", + component_type=ComponentType.CRYSTAL, + mpn="XTAL", + pins={"1": "XIN", "2": "XOUT", "3": "GND"}, + specs=SimpleComponentSpecs(specs_type="crystal", values=values) if values else None, + ), + "C1": Component( + reference="C1", value=c1, footprint="", + component_type=ComponentType.CAPACITOR, + pins={"1": "XIN", "2": "GND"}, + ), + "C2": Component( + reference="C2", value=c2, footprint="", + component_type=ComponentType.CAPACITOR, + pins={"1": "XOUT", "2": "GND"}, + ), + }, + nets={ + "XIN": Net( + name="XIN", net_type=NetType.SIGNAL, + pins=[ + PinConnection(component_ref="X1", pin_number="1"), + PinConnection(component_ref="C1", pin_number="1"), + ], + ), + "XOUT": Net( + name="XOUT", net_type=NetType.SIGNAL, + pins=[ + PinConnection(component_ref="X1", pin_number="2"), + PinConnection(component_ref="C2", pin_number="1"), + ], + ), + "GND": Net( + name="GND", net_type=NetType.GROUND, + pins=[ + PinConnection(component_ref="X1", pin_number="3"), + PinConnection(component_ref="C1", pin_number="2"), + PinConnection(component_ref="C2", pin_number="2"), + ], + ), + }, + ) + + +def test_no_cl_in_specs_is_silent(): + g = _xtal_graph(cl_f=None, c1="18p", c2="18p") + assert check_crystal_cl(g) == [] + + +def test_series_above_cl_without_stray_warns(): + # series of 22p || 22p = 11p — wait we need series > CL. + # 100p || 100p = 50p > CL 18p * 1.25 + g = _xtal_graph(cl_f=18e-12, c1="100p", c2="100p") + findings = check_crystal_cl(g) + assert any(f.rule_id == "PS-XTAL-002" for f in findings) + + +def test_with_stray_mismatch_warns(): + # 18p||18p = 9p + 2p stray = 11p vs CL 18p → below 0.75*18 + g = _xtal_graph(cl_f=18e-12, c1="18p", c2="18p", stray=2e-12) + findings = check_crystal_cl(g) + assert any(f.rule_id == "PS-XTAL-002" for f in findings) + + +def test_simple_project_without_cl_silent(): + from pathlib import Path + from backend.pinscopex.models import DesignGraph + path = Path(__file__).resolve().parents[1] / "simple_project" / "design_graph.json" + g = DesignGraph.model_validate_json(path.read_text()) + assert check_crystal_cl(g) == [] diff --git a/tests/test_functional_groups.py b/tests/test_functional_groups.py new file mode 100644 index 0000000..0b21b19 --- /dev/null +++ b/tests/test_functional_groups.py @@ -0,0 +1,58 @@ +"""Layout F1 functional_groups on simple_project — topology only, no mm.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from backend.pinscopex.functional_groups import build_functional_groups +from backend.pinscopex.models import DesignGraph + +SIMPLE = Path(__file__).resolve().parents[1] / "simple_project" + + +def _graph() -> DesignGraph: + return DesignGraph.model_validate_json( + (SIMPLE / "design_graph.json").read_text(encoding="utf-8"), + ) + + +def test_simple_project_has_mcu_ldo_bridge_groups(): + report = build_functional_groups(_graph()) + assert report.objective == "routing" + refs = {g.ref for g in report.groups} + assert {"U1", "U2", "U3"} <= refs + # No millimetre fields on the report model dump + raw = json.loads(report.model_dump_json()) + blob = json.dumps(raw) + assert "max_distance_mm" not in blob or all( + r.get("max_distance_mm") is None + for g in raw["groups"] + for r in g.get("layout_rules") or [] + ) + + +def test_u3_owns_crystal_load_caps(): + report = build_functional_groups(_graph()) + u3 = next(g for g in report.groups if g.ref == "U3") + sat = {s.ref: s.role_hint for s in u3.satellites} + assert "X1" in sat + assert sat["X1"] == "crystal" + assert sat.get("C9") == "load_cap" or sat.get("C10") == "load_cap" + assert "C9" in sat and "C10" in sat + assert sat["C9"] == "load_cap" + assert sat["C10"] == "load_cap" + + +def test_u1_has_decoupling_or_bulk_on_rails(): + report = build_functional_groups(_graph()) + u1 = next(g for g in report.groups if g.ref == "U1") + roles = {s.role_hint for s in u1.satellites} + assert roles & {"decoupling", "bulk"} + + +def test_domains_cover_all_ics(): + report = build_functional_groups(_graph()) + covered = {r for d in report.domains for r in d.ic_refs} + assert covered == {"U1", "U2", "U3"} + assert all(d.assemble_order for d in report.domains) diff --git a/tests/test_nc_pin_check.py b/tests/test_nc_pin_check.py new file mode 100644 index 0000000..6036d19 --- /dev/null +++ b/tests/test_nc_pin_check.py @@ -0,0 +1,101 @@ +"""NC pin connectivity check.""" + +from __future__ import annotations + +from backend.pinscopex.models import ( + Component, + ComponentConstraints, + ComponentType, + DesignGraph, + Net, + NetType, + Pin, + PinConnection, +) +from backend.pinscopex.nc_pin_check import check_nc_pins + + +def test_nc_pin_on_active_net_warns(): + g = DesignGraph( + components={ + "U1": Component( + reference="U1", value="IC", footprint="", + component_type=ComponentType.IC, mpn="PART", + pins={"1": "SIG", "2": "GND"}, + ), + "R1": Component( + reference="R1", value="10k", footprint="", + component_type=ComponentType.RESISTOR, + pins={"1": "SIG", "2": "GND"}, + ), + }, + nets={ + "SIG": Net( + name="SIG", net_type=NetType.SIGNAL, + pins=[ + PinConnection(component_ref="U1", pin_number="1"), + PinConnection(component_ref="R1", pin_number="1"), + ], + ), + "GND": Net( + name="GND", net_type=NetType.GROUND, + pins=[ + PinConnection(component_ref="U1", pin_number="2"), + PinConnection(component_ref="R1", pin_number="2"), + ], + ), + }, + ) + cmap = { + "PART": ComponentConstraints( + mpn="PART", + pintable=[Pin(number="1", name="NC"), Pin(number="2", name="GND")], + absolute_maximum_ratings=[], + rules=[], + ), + } + findings = check_nc_pins(g, cmap) + assert len(findings) == 1 + assert findings[0].rule_id == "PS-NC-001" + assert findings[0].net == "SIG" + + +def test_nc_pin_on_nc_net_silent(): + g = DesignGraph( + components={ + "U1": Component( + reference="U1", value="IC", footprint="", + component_type=ComponentType.IC, mpn="PART", + pins={"1": "NC"}, + ), + }, + nets={ + "NC": Net( + name="NC", net_type=NetType.UNKNOWN, + pins=[PinConnection(component_ref="U1", pin_number="1")], + ), + }, + ) + cmap = { + "PART": ComponentConstraints( + mpn="PART", + pintable=[Pin(number="1", name="NC")], + absolute_maximum_ratings=[], + rules=[], + ), + } + assert check_nc_pins(g, cmap) == [] + + +def test_no_pintable_silent(): + g = DesignGraph( + components={ + "U1": Component( + reference="U1", value="IC", footprint="", + component_type=ComponentType.IC, mpn="PART", + pins={"1": "SIG"}, + ), + }, + nets={}, + ) + assert check_nc_pins(g, {}) == []