Add Layout F1 functional groups and crystal/NC checks.

Write routing-first domain/satellite topology after graph_build, and
flag crystal CL mismatches and NC pins on active nets without inventing
millimetres.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-12 16:22:56 +02:00
co-authored by Cursor
parent 30aaf55de6
commit 2950446e12
10 changed files with 1020 additions and 3 deletions
+163
View File
@@ -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"
+439
View File
@@ -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
+109
View File
@@ -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
+20
View File
@@ -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",
+4
View File
@@ -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())
+21 -3
View File
@@ -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; 12 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 sullIC (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.
---
+8
View File
@@ -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.
+97
View File
@@ -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) == []
+58
View File
@@ -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)
+101
View File
@@ -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, {}) == []