Pinscope open-source core

Agentic schematic validation: datasheet extraction via Claude Console
Skills, netlist/BOM design graph, per-IC direct datasheet review with
page citations, capacitor derating, Next.js report UI.

Extracted from the Pinscope cloud codebase. Auth and billing live in the
private gateway repo behind stable seams (billing_hook.py, adapter files
listed in CLAUDE.md).
This commit is contained in:
Siddharth Kothari
2026-07-16 21:29:45 -07:00
commit 6672d2be57
254 changed files with 56662 additions and 0 deletions
View File
+88
View File
@@ -0,0 +1,88 @@
"""Build a BOM summary table from the design graph. No AI — pure collation."""
from __future__ import annotations
from backend.pinscopex.models import ComponentType, DesignGraph
from backend.pinscopex.utils import natural_sort_key
def build_bom_summary(
graph: DesignGraph,
datasheet_mpns: set[str] | None = None,
descriptions: dict[str, str] | None = None,
) -> list[dict]:
"""Group components by MPN and collate BOM summary rows.
``descriptions`` is an optional ``{mpn: description}`` map (e.g. from
extracted ``package_info.description``). When supplied, IC rows get a
``description`` field — used by the frontend to show what the chip does
in place of the empty Specs cell.
Returns a list of dicts, each with:
mpn, designators, value, category, specs, description
"""
# Group components by MPN (or by value+type if no MPN)
by_key: dict[str, list] = {}
for comp in graph.components.values():
key = comp.mpn if comp.mpn else f"__no_mpn__{comp.value}__{comp.component_type}"
by_key.setdefault(key, []).append(comp)
rows = []
for comps in by_key.values():
first = comps[0]
designators = sorted(
[c.reference for c in comps], key=natural_sort_key
)
# Extract display-friendly specs
specs_dict = None
if first.specs:
if hasattr(first.specs, "values"):
# SimpleComponentSpecs — flatten the values dict
raw = {k: v for k, v in first.specs.values.items() if v is not None}
else:
raw = first.specs.model_dump(exclude={"specs_type"})
# Drop None values and internal numeric fields
raw = {
k: v for k, v in raw.items()
if v is not None and k not in ("value_ohms", "value_farads", "value_henries")
}
specs_dict = raw if raw else None
has_ds = bool(
first.mpn
and datasheet_mpns is not None
and first.mpn in datasheet_mpns
)
description = None
if (
descriptions is not None
and first.mpn
and first.component_type == ComponentType.IC
):
description = descriptions.get(first.mpn)
rows.append({
"mpn": first.mpn,
"designators": designators,
"value": first.value,
"category": first.component_subtype,
"specs": specs_dict,
"description": description,
"has_datasheet": has_ds,
})
# Sort: ICs first, then passives, then others; within each by category then MPN
def sort_key(row: dict) -> tuple:
cat = row["category"] or ""
if cat.startswith("ic"):
group = 0
elif cat.startswith("passive"):
group = 1
else:
group = 2
return (group, cat, row["mpn"] or "")
rows.sort(key=sort_key)
return rows
+123
View File
@@ -0,0 +1,123 @@
"""Build a capacitor voltage derating table from the design graph. No AI — pure computation."""
from __future__ import annotations
import re
from backend.pinscopex.models import ComponentType, DesignGraph, NetType
from backend.pinscopex.utils import natural_sort_key
# Dielectric strings that indicate ceramic capacitors
_CERAMIC_DIELECTRICS = {"X7R", "X5R", "C0G", "NP0", "Y5V", "X7S", "X6S", "X8R", "C0G (NP0)"}
def _parse_voltage_rating(s: str | None) -> float | None:
"""Extract numeric voltage from a rating string like '16V', '25V', '2.5V'."""
if not s:
return None
m = re.match(r"([\d.]+)", s)
return float(m.group(1)) if m else None
def _dielectric_category(component_subtype: str | None, dielectric: str | None) -> str | None:
"""Map component subtype / dielectric to a derating category."""
if component_subtype:
low = component_subtype.lower()
if "tantalum" in low:
return "tantalum"
if "electrolytic" in low:
return "electrolytic"
if "ceramic" in low:
return "ceramic"
if dielectric:
upper = dielectric.upper().strip()
if upper in _CERAMIC_DIELECTRICS or any(d in upper for d in _CERAMIC_DIELECTRICS):
return "ceramic"
low = dielectric.lower()
if "tantalum" in low or low == "ta":
return "tantalum"
if "electrolytic" in low or low == "al":
return "electrolytic"
# Default to ceramic (most common)
return "ceramic"
def build_derating_table(graph: DesignGraph) -> list[dict]:
"""Build a capacitor voltage derating table from the design graph.
For each capacitor, determines:
- Rated voltage (from specs)
- Operating voltage (from connected net voltages)
- Dielectric category (ceramic / tantalum / electrolytic)
Returns a sorted list of dicts, one per capacitor designator.
"""
rows: list[dict] = []
for comp in graph.components.values():
if comp.component_type != ComponentType.CAPACITOR:
continue
# Rated voltage from specs
rated_v: float | None = None
value_fmt: str | None = None
dielectric: str | None = None
if comp.specs and hasattr(comp.specs, "voltage_rating_v"):
rated_v = _parse_voltage_rating(comp.specs.voltage_rating_v)
value_fmt = getattr(comp.specs, "value_formatted", None)
dielectric = getattr(comp.specs, "dielectric", None)
# Operating voltage: max non-zero voltage among connected nets
op_voltage: float | None = None
op_source: str | None = None
for net_name in comp.pins.values():
net = graph.nets.get(net_name)
if net and net.voltage is not None and net.voltage > 0:
if op_voltage is None or net.voltage > op_voltage:
op_voltage = net.voltage
op_source = net_name
# Determine net+ (highest voltage) and net- (ground / lowest voltage).
# Deduplicate net names (multi-pin caps may connect twice to same net).
seen: set[str] = set()
connected: list[tuple[str, float | None, NetType | None]] = []
for net_name in comp.pins.values():
if net_name in seen:
continue
seen.add(net_name)
net = graph.nets.get(net_name)
v = net.voltage if net else None
nt = net.net_type if net else None
connected.append((net_name, v, nt))
net_plus: str | None = None
net_minus: str | None = None
if len(connected) == 1:
# Single-net cap (both pins on same net) — show as net+
net_plus = connected[0][0]
elif len(connected) >= 2:
# Sort: ground first, then ascending by voltage (None < any number)
by_v = sorted(connected, key=lambda c: (
c[2] != NetType.GROUND, # ground nets first
c[1] is not None, # None before numbers
c[1] or 0, # ascending voltage
))
net_minus = by_v[0][0]
net_plus = by_v[-1][0]
rows.append({
"designator": comp.reference,
"mpn": comp.mpn,
"value_formatted": value_fmt,
"rated_voltage_v": rated_v,
"operating_voltage_v": op_voltage,
"operating_voltage_source": op_source,
"net_plus": net_plus,
"net_minus": net_minus,
"dielectric_category": _dielectric_category(comp.component_subtype, dielectric),
})
rows.sort(key=lambda r: natural_sort_key(r["designator"]))
return rows
+374
View File
@@ -0,0 +1,374 @@
"""Build a DesignGraph deterministically from netlist + BOM + extracted datasheets."""
from __future__ import annotations
import json
import re
from pathlib import Path
from backend.pinscopex.utils import safe_mpn
from backend.pinscopex.models import (
Component,
ComponentConstraints,
ComponentModel,
ComponentSpecs,
ComponentType,
DesignGraph,
Net,
NetType,
PinConnection,
SimpleComponentSpecs,
)
# Datasheets are loaded here for pin-name enrichment during graph build,
# but NOT embedded into the graph. The validator loads them separately.
from backend.pinscopex.parsers import parse_bom, parse_netlist_any
from backend.pinscopex.resolve_passives import SkippedItem, resolve_bom, resolved_to_specs
# ---------------------------------------------------------------------------
# Component type classification
# ---------------------------------------------------------------------------
_PREFIX_TYPE: dict[str, ComponentType] = {
"R": ComponentType.RESISTOR,
"C": ComponentType.CAPACITOR,
"L": ComponentType.INDUCTOR,
"U": ComponentType.IC,
"IC": ComponentType.IC,
"J": ComponentType.CONNECTOR,
"X": ComponentType.CRYSTAL,
"Y": ComponentType.CRYSTAL,
"D": ComponentType.DISCRETE,
"LED": ComponentType.DISCRETE,
"Q": ComponentType.DISCRETE,
"T": ComponentType.TRANSFORMER,
"F": ComponentType.FUSE,
"SW": ComponentType.SWITCH,
"TP": ComponentType.TEST_POINT,
"FM": ComponentType.FIDUCIAL,
"MH": ComponentType.MECHANICAL,
}
# Fallback footprint patterns for designators whose prefix isn't a known
# EE convention (e.g. pure-numeric refs like "4", descriptive refs like
# "CV GND", "CAN BUS IN", "12V ACTIVE"). Order matters — first match wins.
_FOOTPRINT_TYPE_PATTERNS: list[tuple[re.Pattern, ComponentType]] = [
(re.compile(
r"(?i)(?:^|[\s_])("
r"CONN(?:_|\b)|TERM(?:\b|_BLK)|HEADER|SOCKET|JACK|RECEPTACLE|PLUG|"
r"SCREW\s*TERM|PINHEADER|BARREL|BANANA|XT30|XT60|XT90|USB|"
r"WURTH\s*746\d|TE\s*282834|TE\s*2828\d|MOLEX|JST"
r")"
), ComponentType.CONNECTOR),
(re.compile(r"(?i)TestPoint|TEST[_\s]POINT|\bTP_"), ComponentType.TEST_POINT),
(re.compile(r"(?i)^LED[\s_]|\bLED\s+\d{3,4}"), ComponentType.DISCRETE),
(re.compile(r"(?i)^CAP[\s_]|\bCAP_|CAPACITOR"), ComponentType.CAPACITOR),
(re.compile(r"(?i)^RES[\s_]|\bRES_|RESISTOR"), ComponentType.RESISTOR),
(re.compile(r"(?i)^IND[\s_]|\bIND_|INDUCTOR"), ComponentType.INDUCTOR),
(re.compile(r"(?i)DO214|DO220|SOD\d|SMD?J5|SMB_|SOT-?23"), ComponentType.DISCRETE),
]
def _classify_component(ref: str, footprint: str) -> ComponentType:
"""Classify a component by its reference prefix, with footprint fallback."""
prefix = re.match(r"^[A-Za-z]+", ref)
if prefix:
t = _PREFIX_TYPE.get(prefix.group())
if t is not None:
return t
# Fallback: use footprint hints when the ref prefix isn't recognised
# (e.g. pure-numeric refs, or descriptive refs like "CV GND", "12V ACTIVE")
fp = footprint or ""
for pattern, ctype in _FOOTPRINT_TYPE_PATTERNS:
if pattern.search(fp):
return ctype
return ComponentType.UNKNOWN
# ---------------------------------------------------------------------------
# Net type / voltage inference
# ---------------------------------------------------------------------------
# Patterns for common power rail names -> nominal voltage
_VOLTAGE_RE: list[tuple[re.Pattern, float]] = [
(re.compile(r"^\+(\d+)V(\d+)$"), 0), # +3V3 -> 3.3, +1V35 -> 1.35
(re.compile(r"^\+(\d+(?:\.\d+)?)V$"), 0), # +5V -> 5.0, +12V -> 12.0
]
def _parse_rail_voltage(name: str) -> float | None:
"""Try to extract a numeric voltage from a power-rail net name.
Handles patterns like: +3V3, +5V, VDD_1V8, DVDD3V3, VBUS_5V0, etc.
"""
# +3V3 style: digits + V + digits -> "3.3"
m = re.match(r"^\+(\d+)V(\d+)$", name)
if m:
return float(f"{m.group(1)}.{m.group(2)}")
# +5V style
m = re.match(r"^\+(\d+(?:\.\d+)?)V$", name)
if m:
return float(m.group(1))
# Embedded voltage: *_1V8, *_3V3, *1V35, *3V3, etc.
m = re.search(r"(\d+)V(\d+)", name)
if m:
return float(f"{m.group(1)}.{m.group(2)}")
# Embedded voltage: *_5V0, *_12V, *5V, etc.
m = re.search(r"(\d+(?:\.\d+)?)V(?:\d|$|_)", name)
if m:
return float(m.group(1))
return None
# Net name prefixes that indicate power rails (case-insensitive)
_POWER_PREFIXES = (
"VCC", "VDD", "VBUS", "VBAT", "VSYS", "VSUP", "VPWR",
"AVDD", "DVDD", "AVCC", "DVCC", "PVDD", "PVCC",
"V_",
)
# Net name suffixes that indicate ground (case-insensitive)
_GROUND_SUFFIXES = ("_GND", "GND")
_GROUND_NAMES = {"GND", "AGND", "DGND", "PGND", "VSS", "AVSS", "DVSS", "PVSS"}
def _infer_net_properties(name: str) -> tuple[NetType, float | None]:
"""Deterministically classify a net by its name."""
upper = name.upper()
# Ground nets — exact names and suffixes
if upper in _GROUND_NAMES or any(upper.endswith(s) for s in _GROUND_SUFFIXES):
return NetType.GROUND, 0.0
# Power rails: names starting with "+"
if name.startswith("+"):
voltage = _parse_rail_voltage(name)
return NetType.POWER, voltage
# Power rails: common prefixes (VDD, VCC, VBUS, etc.)
if any(upper.startswith(p) for p in _POWER_PREFIXES):
voltage = _parse_rail_voltage(name)
return NetType.POWER, voltage
# Everything else is a signal
return NetType.SIGNAL, None
# ---------------------------------------------------------------------------
# Datasheet loading
# ---------------------------------------------------------------------------
def _load_datasheets(directory: str | Path) -> dict[str, tuple[Path, ComponentConstraints]]:
"""Load all extracted datasheet JSONs, keyed by MPN."""
result: dict[str, tuple[Path, ComponentConstraints]] = {}
dirpath = Path(directory)
if not dirpath.is_dir():
return result
for json_file in dirpath.glob("*.json"):
raw = json.loads(json_file.read_text())
constraints = ComponentConstraints.model_validate(raw)
result[constraints.mpn] = (json_file, constraints)
return result
def _match_datasheet(
mpn: str | None,
datasheets: dict[str, tuple[Path, ComponentConstraints]],
) -> tuple[Path | None, ComponentConstraints | None]:
"""Match a BOM MPN to an extracted datasheet. Tries exact then normalized."""
if not mpn:
return None, None
# Exact match
if mpn in datasheets:
return datasheets[mpn]
# Normalize: strip common suffixes, lowercase compare
def _norm(s: str) -> str:
return re.sub(r"[/_\-\s]", "", s).upper()
mpn_norm = _norm(mpn)
for ds_mpn, (path, constraints) in datasheets.items():
if _norm(ds_mpn) == mpn_norm:
return path, constraints
return None, None
# ---------------------------------------------------------------------------
# Component model loading / saving (passive specs cache)
# ---------------------------------------------------------------------------
def _load_component_models(directory: str | Path) -> dict[str, ComponentSpecs]:
"""Load all component model JSONs, keyed by MPN."""
result: dict[str, ComponentSpecs] = {}
dirpath = Path(directory)
if not dirpath.is_dir():
return result
for json_file in dirpath.glob("*.json"):
raw = json.loads(json_file.read_text())
model = ComponentModel.model_validate(raw)
result[model.mpn] = model.specs
return result
def _save_component_model(mpn: str, specs: ComponentSpecs, directory: Path) -> None:
"""Save a ComponentModel to the component-models directory."""
directory.mkdir(parents=True, exist_ok=True)
safe_name = safe_mpn(mpn)
model = ComponentModel(mpn=mpn, specs=specs)
(directory / f"{safe_name}.json").write_text(
model.model_dump_json(indent=2) + "\n"
)
# ---------------------------------------------------------------------------
# Graph builder
# ---------------------------------------------------------------------------
def build_graph(
netlist_path: str | Path,
bom_path: str | Path,
datasheets_dir: str | Path = "datasheets/extracted",
patterns_dir: str | Path = "component-patterns",
component_models_dir: str | Path = "component-models",
*,
reference_col: str = "Reference",
mpn_col: str = "Manufacturer Part Number",
skipped: list[SkippedItem] | None = None,
include_subdesigns: set[str] | None = None,
) -> DesignGraph:
"""Build a DesignGraph deterministically from project files.
Steps:
1. Parse netlist -> parts (ref, footprint) and nets (name, pin connections)
2. Parse BOM -> values, MPNs, LCSC codes per reference
3. Load extracted datasheets and match by MPN
4. Resolve passive specs from patterns + cached component models
5. Assemble components with classified type, linked constraints, and specs
6. Assemble nets with inferred type/voltage and enriched pin names
"""
# Parse BOM first so we can feed known refs into the netlist parser —
# PADS-PCB netlists allow multi-word designators (e.g. "CV GND"), which
# 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)
parts, raw_nets, _ = parse_netlist_any(
netlist_path,
known_refs=set(bom.keys()),
include_subdesigns=include_subdesigns,
)
datasheets = _load_datasheets(datasheets_dir)
# --- Resolve passive specs ------------------------------------------------
models_dir = Path(component_models_dir)
mpn_specs: dict[str, ComponentSpecs] = _load_component_models(models_dir)
mpn_subtype: dict[str, str] = {} # MPN -> component_subtype from patterns
for rp in resolve_bom(bom_path, patterns_dir, reference_col=reference_col, mpn_col=mpn_col, skipped=skipped):
if rp.component_subtype:
mpn_subtype[rp.mpn] = rp.component_subtype
if rp.mpn not in mpn_specs:
try:
specs = resolved_to_specs(rp)
mpn_specs[rp.mpn] = specs
_save_component_model(rp.mpn, specs, models_dir)
except Exception as e:
if skipped is not None:
skipped.append(SkippedItem(rp.mpn, "passive_specs", str(e)))
components: dict[str, Component] = {}
nets: dict[str, Net] = {}
# --- Build components ---------------------------------------------------
# Some PADS-PCB netlist exports omit the *PART* section. When that happens
# derive the component list from BOM entries + refs found in nets so the
# graph is still fully populated.
if not parts:
net_refs = {ref for pins in raw_nets.values() for ref, _ in pins}
all_refs = set(bom.keys()) | net_refs
parts = {ref: bom.get(ref, {}).get("footprint", "") for ref in all_refs}
for ref, footprint in parts.items():
bom_entry = bom.get(ref, {})
value = bom_entry.get("value", "")
mpn = bom_entry.get("mpn")
components[ref] = Component(
reference=ref,
value=value,
footprint=footprint,
component_type=_classify_component(ref, footprint),
mpn=mpn,
pins={},
)
# Build MPN -> constraints lookup for pin-name enrichment and subtype
_constraints_by_ref: dict[str, ComponentConstraints] = {}
for ref, comp in components.items():
if comp.mpn:
_, constraints = _match_datasheet(comp.mpn, datasheets)
if constraints:
_constraints_by_ref[ref] = constraints
if constraints.component_subtype:
comp.component_subtype = constraints.component_subtype
# Attach specs (passive or simple component) and subtype
if comp.mpn in mpn_specs:
comp.specs = mpn_specs[comp.mpn]
# SimpleComponentSpecs carries its own subtype
if not comp.component_subtype:
s = mpn_specs[comp.mpn]
if hasattr(s, "component_subtype") and s.component_subtype:
comp.component_subtype = s.component_subtype
if not comp.component_subtype and comp.mpn in mpn_subtype:
comp.component_subtype = mpn_subtype[comp.mpn]
# --- Build nets and wire up pins ----------------------------------------
for net_name, pin_list in raw_nets.items():
net_type, voltage = _infer_net_properties(net_name)
pin_connections: list[PinConnection] = []
for ref, pin_num in pin_list:
# Record on the component side: pin -> net
if ref in components:
components[ref].pins[pin_num] = net_name
# Enrich pin name from datasheet (IC constraints or simple specs)
pin_name = None
constraints = _constraints_by_ref.get(ref)
if constraints:
pin_obj = constraints.pin_by_number(pin_num)
if pin_obj:
pin_name = pin_obj.name
elif ref in components and components[ref].mpn:
# Check SimpleComponentSpecs pintable
s = mpn_specs.get(components[ref].mpn)
if isinstance(s, SimpleComponentSpecs) and s.pintable:
pin_obj = s.pin_by_number(pin_num)
if pin_obj:
pin_name = pin_obj.name
pin_connections.append(PinConnection(
component_ref=ref,
pin_number=pin_num,
pin_name=pin_name,
))
nets[net_name] = Net(
name=net_name,
net_type=net_type,
voltage=voltage,
pins=pin_connections,
)
return DesignGraph(components=components, nets=nets)
+320
View File
@@ -0,0 +1,320 @@
"""Deterministic LED forward-current check.
For each LED, compute the worst-case forward current per channel
``I = (V_rail - Vf) / R`` (0 V driver drop) and compare against the LED's
datasheet forward-current rating. Over-current is a hard ERROR; ambiguous cases
(unknown rail, no rating, no resistor found, possible constant-current driver)
are left alone or flagged WARNING rather than guessed. One finding per LED —
the worst offending channel.
All inputs come straight off the design graph — the LED's extracted specs
(``Component.specs.values``: per-colour ``forward_voltage_*_v``,
``forward_current_per_channel_a`` / ``forward_current_a``) and the series
resistor's ``value_ohms`` (or parsed ``value`` string). Nothing is re-fetched.
"""
from __future__ import annotations
import re
from backend.pinscopex.models import ComponentType, DesignGraph, Finding, NetType
from backend.pinscopex.resolve_passives import _parse_spice_value
_COLOR_TOKENS = {
"R": "red", "RED": "red",
"G": "green", "GRN": "green", "GREEN": "green",
"B": "blue", "BLU": "blue", "BLUE": "blue",
}
# ---------------------------------------------------------------------------
# Value parsing
# ---------------------------------------------------------------------------
def _num(v: object) -> float | None:
"""Parse a free-form spec value ("13mA", "2.8V", "3.3V typ, 4V max", or a
bare float) to a float in base units, or None."""
if v is None:
return None
if isinstance(v, (int, float)):
return float(v)
s = str(v).strip()
for cand in (s, *re.findall(r"[-+]?\d*\.?\d+\s*[a-zA-Zµ]*", s)):
cand = cand.strip()
if not cand:
continue
try:
return _parse_spice_value(cand)
except ValueError:
pass
m = re.match(r"^[-+]?\d*\.?\d+", cand)
if m:
try:
return float(m.group(0))
except ValueError:
pass
return None
def _parse_resistance(v: object) -> float | None:
"""Parse a resistance string to ohms: "5.6K"->5600, "5K6"->5600,
"150R"->150, "4R7"->4.7, "1M"->1e6, "0"->0."""
if v is None:
return None
if isinstance(v, (int, float)):
return float(v)
t = str(v).strip().upper().replace("OHMS", "").replace("OHM", "").replace("Ω", "").replace(" ", "")
if not t:
return None
mult = {"R": 1.0, "K": 1e3, "M": 1e6, "G": 1e9}
m = re.match(r"^(\d+)([RKMG])(\d+)$", t) # 5K6, 4R7, 1M5
if m:
return (float(m.group(1)) + float(f"0.{m.group(3)}")) * mult[m.group(2)]
m = re.match(r"^(\d*\.?\d+)([RKMG])$", t) # 5.6K, 150R, 1M
if m:
return float(m.group(1)) * mult[m.group(2)]
try:
return float(t)
except ValueError:
return None
def _spec(values: dict, *keys: str) -> float | None:
for k in keys:
if k in values:
n = _num(values[k])
if n is not None:
return n
return None
def _imax(values: dict) -> float | None:
"""LED forward-current rating in amps."""
i = _spec(values, "forward_current_per_channel_a", "forward_current_a",
"max_forward_current_a", "if_max_a")
if i is None:
return None
# A per-channel LED current >= 1 A is almost certainly mA written without a
# unit (e.g. "13" meaning 13 mA) — scale down.
if i >= 1.0:
i = i / 1000.0
return i
def _vf(values: dict, color: str | None) -> float | None:
vf = None
if color:
vf = _spec(values, f"forward_voltage_{color}_v")
if vf is None:
vf = _spec(values, "forward_voltage_v", "vf_v")
if vf is None:
cands = [_spec(values, f"forward_voltage_{c}_v") for c in ("red", "green", "blue")]
cands = [c for c in cands if c is not None]
vf = min(cands) if cands else None # lowest Vf = most conservative (highest I)
if vf is not None and vf > 20: # mV given without scaling
vf = vf / 1000.0
return vf
# ---------------------------------------------------------------------------
# Graph helpers
# ---------------------------------------------------------------------------
def _net_voltage(graph: DesignGraph, net_name: str | None) -> float | None:
if not net_name:
return None
net = graph.nets.get(net_name)
return net.voltage if net else None
def _is_rail_net(graph: DesignGraph, net_name: str) -> bool:
net = graph.nets.get(net_name)
if not net:
return False
return net.net_type in (NetType.POWER, NetType.GROUND) or net.voltage is not None
def _series_resistor(graph: DesignGraph, net_name: str, exclude_ref: str):
"""Return (resistor_ref, ohms, far_net) for a 2-terminal series resistor on a
private (degree-2) net, or None. Requiring degree 2 ensures the resistor is
truly in series with the LED leg, not merely sharing a bus/rail net."""
net = graph.nets.get(net_name)
if not net or len(net.pins) != 2:
return None
for pc in net.pins:
if pc.component_ref == exclude_ref:
continue
c = graph.components.get(pc.component_ref)
if not c or c.component_type != ComponentType.RESISTOR:
continue
rval = getattr(c.specs, "value_ohms", None) if c.specs else None
if rval is None:
rval = _parse_resistance(c.value)
if rval is None or rval <= 0:
continue
far = next((n for n in c.pins.values() if n != net_name), None)
return (pc.component_ref, float(rval), far)
return None
def _leg_to_ic(graph: DesignGraph, net_name: str, exclude_ref: str) -> bool:
"""True if an IC sits on this leg net (possible constant-current driver)."""
for r in graph.components_on_net(net_name):
if r == exclude_ref:
continue
c = graph.components.get(r)
if c and c.component_type == ComponentType.IC:
return True
return False
def _leg_color(pid: str, comp) -> str | None:
if pid.upper() in _COLOR_TOKENS:
return _COLOR_TOKENS[pid.upper()]
specs = comp.specs
pin = specs.pin_by_number(pid) if specs and hasattr(specs, "pin_by_number") else None
if pin:
for tok in re.split(r"[\s_/-]+", pin.name.upper()):
if tok in _COLOR_TOKENS:
return _COLOR_TOKENS[tok]
return None
# ---------------------------------------------------------------------------
# Per-LED check
# ---------------------------------------------------------------------------
def check_led_current(graph: DesignGraph) -> list[Finding]:
findings: list[Finding] = []
for ref in sorted(graph.components_by_subtype("discrete.led")):
comp = graph.components.get(ref)
if not comp or not comp.specs:
continue
values = getattr(comp.specs, "values", None)
if not values:
continue
imax = _imax(values)
if imax is None:
continue # no forward-current rating -> nothing to check against
finding = _check_led(graph, ref, comp, values, imax)
if finding is not None:
findings.append(finding)
return findings
def _check_led(graph, ref, comp, values, imax) -> Finding | None:
pins = comp.pins # pid -> net
pin_volts = [v for v in (_net_voltage(graph, n) for n in pins.values()) if v is not None]
# Channels carrying current sit on private (signal) nets; for a 2-pin LED the
# single channel is whichever pin actually has a series resistor.
if len(pins) <= 2:
leg = next(
((pid, net, _series_resistor(graph, net, ref))
for pid, net in pins.items()
if _series_resistor(graph, net, ref)),
None,
)
if leg is None:
cand = next(((pid, net) for pid, net in pins.items()
if not _is_rail_net(graph, net)), None)
legs_iter = [(cand[0], cand[1], None)] if cand else []
else:
legs_iter = [leg]
else:
legs_iter = [
(pid, net, _series_resistor(graph, net, ref))
for pid, net in pins.items()
if not _is_rail_net(graph, net)
]
worst = None # (i, color, net, vrail, vf, rval, rref)
no_res = None # (color, net, vrail, vf)
for pid, net, res in legs_iter:
color = _leg_color(pid, comp)
vf = _vf(values, color)
cand = list(pin_volts)
if res and res[2]:
fv = _net_voltage(graph, res[2])
if fv is not None:
cand.append(fv)
vrail = max(cand) if cand else None
if res is None:
if no_res is None and vrail is not None and vrail > 0 and not _leg_to_ic(graph, net, ref):
no_res = (color, net, vrail, vf)
continue
rref, rval, _far = res
if vrail is None or vf is None or vrail <= vf or rval <= 0:
continue
i = (vrail - vf) / rval
if i > imax and (worst is None or i > worst[0]):
worst = (i, color, net, vrail, vf, rval, rref)
if worst is not None:
i, color, net, vrail, vf, rval, rref = worst
return _over_current_finding(ref, comp, net, color, vrail, vf, rval, rref, imax, i)
if no_res is not None:
color, net, vrail, vf = no_res
return _no_resistor_finding(ref, comp, net, color, vrail, vf, imax)
return None
def _chan(color: str | None) -> str:
return f"{color} channel" if color else "LED"
def _over_current_finding(ref, comp, net, color, vrail, vf, rval, rref, imax, i) -> Finding:
rmin = (vrail - vf) / imax
return Finding(
designator=ref,
mpn=comp.mpn or "",
aspect="led_current",
source="led_current_check",
source_page=None,
status="ERROR",
finding=(
f"{ref} {_chan(color)} forward current is ~{i * 1000:.0f} mA, "
f"exceeding its {imax * 1000:.0f} mA forward-current rating."
),
why=(
f"With the supply at {vrail:.1f} V and Vf≈{vf:.1f} V, series resistor "
f"{rref} ({rval:.0f} Ω) on net '{net}' passes "
f"~({vrail:.1f}{vf:.1f})/{rval:.0f} = {i * 1000:.0f} mA (worst case, "
f"0 V driver drop) — above the {imax * 1000:.0f} mA rating."
),
recommendation=(
f"Increase the series resistor to at least {rmin:.0f} Ω to keep the "
f"{_chan(color)} at or below {imax * 1000:.0f} mA."
),
reference=f"{comp.mpn or ref} LED specs",
)
def _no_resistor_finding(ref, comp, net, color, vrail, vf, imax) -> Finding:
rec = "Add a series current-limiting resistor, or confirm a constant-current driver."
if vf is not None and vrail > vf:
rec = (
f"Add a series resistor of at least {((vrail - vf) / imax):.0f} Ω "
f"(or confirm a constant-current driver)."
)
return Finding(
designator=ref,
mpn=comp.mpn or "",
aspect="led_current",
source="led_current_check",
source_page=None,
status="WARNING",
finding=(
f"Unverified: {ref} {_chan(color)} has no series current-limiting "
f"resistor on net '{net}'."
),
why=(
f"The {_chan(color)} on net '{net}' has no series resistor between the "
f"LED and the {vrail:.1f} V supply. If it is not driven by a "
f"constant-current source, forward current can exceed the "
f"{imax * 1000:.0f} mA rating."
),
recommendation=rec,
reference=f"{comp.mpn or ref} LED specs",
)
+411
View File
@@ -0,0 +1,411 @@
"""Pydantic models for PinscopeX: datasheet constraints and design graph."""
from __future__ import annotations
from enum import Enum
from typing import Annotated, Any, Literal
from pydantic import BaseModel, Discriminator, Field, Tag, field_validator
class Pin(BaseModel):
number: int | str
name: str
description: str | None = None
functions: list[str] | None = None
class PackageInfo(BaseModel):
base_family: str
package: str
pin_count: int
description: str | None = None
class AbsMaxRating(BaseModel):
parameter: str
min: float | None = None
max: float | None = None
unit: str
source_page: int
class Rule(BaseModel):
rule_id: str | None = None # {MPN}-{001}
description: str
source_page: int
def _check_subtype(v: object) -> str | None:
"""Shared pre-validator for component_subtype fields."""
if v is None or v == "":
return None
from backend.pinscopex.taxonomy import validate_subtype
return validate_subtype(str(v))
class ComponentConstraints(BaseModel):
mpn: str
model_version: str = "1.0.0" # semver; bumped on prune (patch) or skill update (minor)
component_subtype: str | None = None # dotted taxonomy path, e.g. "ic.ldo", "ic.mcu"
package_info: PackageInfo | None = None
pintable: list[Pin]
absolute_maximum_ratings: list[AbsMaxRating]
rules: list[Rule]
_validate_subtype = field_validator("component_subtype", mode="before")(
staticmethod(_check_subtype)
)
def pin_by_number(self, number: int | str) -> Pin | None:
"""Look up a pin by its number."""
for p in self.pintable:
if str(p.number) == str(number):
return p
return None
# ---------------------------------------------------------------------------
# Design graph models
# ---------------------------------------------------------------------------
class NetType(str, Enum):
POWER = "power"
GROUND = "ground"
SIGNAL = "signal"
UNKNOWN = "unknown"
class ComponentType(str, Enum):
RESISTOR = "resistor"
CAPACITOR = "capacitor"
INDUCTOR = "inductor"
IC = "ic"
CONNECTOR = "connector"
CRYSTAL = "crystal"
DISCRETE = "discrete"
TRANSFORMER = "transformer"
FUSE = "fuse"
SWITCH = "switch"
TEST_POINT = "test_point"
FIDUCIAL = "fiducial"
MECHANICAL = "mechanical"
UNKNOWN = "unknown"
# ---------------------------------------------------------------------------
# Component specs taxonomy — type-specific, standardised-unit models
# ---------------------------------------------------------------------------
class ResistorSpecs(BaseModel):
"""Standardised resistor parameters. Value always in ohms."""
specs_type: Literal["resistor"] = "resistor"
component_subtype: str | None = None # e.g. "passive.resistor"
value_ohms: float
value_formatted: str
tolerance: str | None = None # "±1%" or "±0.5ohm"
package: str | None = None
power_rating_w: str | None = None
_validate_subtype = field_validator("component_subtype", mode="before")(
staticmethod(_check_subtype)
)
class CapacitorSpecs(BaseModel):
"""Standardised capacitor parameters. Value always in farads."""
specs_type: Literal["capacitor"] = "capacitor"
component_subtype: str | None = None # e.g. "passive.capacitor.ceramic"
value_farads: float
value_formatted: str
tolerance: str | None = None # "±10%" or "±0.25pF"
package: str | None = None
voltage_rating_v: str | None = None
dielectric: str | None = None
_validate_subtype = field_validator("component_subtype", mode="before")(
staticmethod(_check_subtype)
)
class InductorSpecs(BaseModel):
"""Standardised inductor parameters. Value always in henries."""
specs_type: Literal["inductor"] = "inductor"
component_subtype: str | None = None # e.g. "passive.inductor" or "passive.ferrite_bead"
value_henries: float
value_formatted: str
tolerance: str | None = None # "±5%" or "±0.1uH"
package: str | None = None
current_rating_a: str | None = None
dcr_ohms: float | None = None
_validate_subtype = field_validator("component_subtype", mode="before")(
staticmethod(_check_subtype)
)
class SimpleComponentSpecs(BaseModel):
"""Specs for discrete/simple components. Schema defined in taxonomy JSON."""
specs_type: str # taxonomy type: "discrete", "connector", "crystal", etc.
component_subtype: str | None = None
values: dict[str, float | str | None] = {}
pintable: list[Pin] = []
package_info: PackageInfo | None = None
_validate_subtype = field_validator("component_subtype", mode="before")(
staticmethod(_check_subtype)
)
def pin_by_number(self, number: int | str) -> Pin | None:
"""Look up a pin by its number."""
for p in self.pintable:
if str(p.number) == str(number):
return p
return None
def _specs_tag(v: Any) -> str:
"""Route to the correct specs model based on specs_type."""
st = v.get("specs_type") if isinstance(v, dict) else v.specs_type
return st if st in ("resistor", "capacitor", "inductor") else "simple"
ComponentSpecs = Annotated[
Annotated[ResistorSpecs, Tag("resistor")]
| Annotated[CapacitorSpecs, Tag("capacitor")]
| Annotated[InductorSpecs, Tag("inductor")]
| Annotated[SimpleComponentSpecs, Tag("simple")],
Discriminator(_specs_tag),
]
class ComponentModel(BaseModel):
"""Persisted specs file — one per MPN in component-models/."""
mpn: str
specs: ComponentSpecs
# ---------------------------------------------------------------------------
# Design graph models
# ---------------------------------------------------------------------------
class PinConnection(BaseModel):
"""A pin on a component that participates in a net."""
component_ref: str
pin_number: str
pin_name: str | None = None # enriched from datasheet pintable
class Net(BaseModel):
"""An electrical net with mutable type/voltage for agent refinement."""
name: str
net_type: NetType = NetType.UNKNOWN
voltage: float | None = None
pins: list[PinConnection] = []
class Component(BaseModel):
"""A placed component in the design graph (topology only)."""
reference: str
value: str
footprint: str
component_type: ComponentType = ComponentType.UNKNOWN
component_subtype: str | None = None # dotted taxonomy path, e.g. "ic.ldo", "ic.mcu"
mpn: str | None = None
pins: dict[str, str] = {} # pin_number -> net_name
specs: ComponentSpecs | None = None
_validate_subtype = field_validator("component_subtype", mode="before")(
staticmethod(_check_subtype)
)
class DesignGraph(BaseModel):
"""
Bipartite design graph: Components <-> Nets.
Traversal paths:
component.pins[pin_num] -> net_name -> graph.nets[net_name].pins -> other components
net.pins[i].component_ref -> graph.components[ref] -> its other pins/nets
"""
components: dict[str, Component] = {}
nets: dict[str, Net] = {}
# -- Traversal helpers --------------------------------------------------
def components_on_net(self, net_name: str) -> list[str]:
"""All component refs connected to a net."""
net = self.nets.get(net_name)
if not net:
return []
return list({pc.component_ref for pc in net.pins})
def nets_of_component(self, ref: str) -> list[str]:
"""All net names a component touches."""
comp = self.components.get(ref)
if not comp:
return []
return list(set(comp.pins.values()))
def neighbors(self, ref: str) -> dict[str, list[str]]:
"""Components sharing a net with *ref*, grouped by net name."""
result: dict[str, list[str]] = {}
for net_name in self.nets_of_component(ref):
others = [r for r in self.components_on_net(net_name) if r != ref]
if others:
result[net_name] = others
return result
def components_by_type(self, comp_type: ComponentType) -> list[str]:
"""All refs matching a component type."""
return [r for r, c in self.components.items() if c.component_type == comp_type]
def power_nets(self) -> list[Net]:
"""All power and ground nets."""
return [n for n in self.nets.values() if n.net_type in (NetType.POWER, NetType.GROUND)]
def capacitors_on_net(self, net_name: str) -> list[str]:
"""Capacitor refs connected to a net (useful for decoupling checks)."""
return [
r for r in self.components_on_net(net_name)
if self.components[r].component_type == ComponentType.CAPACITOR
]
def components_by_subtype(self, prefix: str) -> list[str]:
"""All refs whose component_subtype starts with *prefix*.
Examples:
components_by_subtype("ic.power") -> all power ICs
components_by_subtype("passive.capacitor") -> all capacitors
components_by_subtype("passive") -> all passives
"""
prefix_dot = prefix if prefix.endswith(".") else prefix + "."
return [
r for r, c in self.components.items()
if c.component_subtype and (
c.component_subtype == prefix
or c.component_subtype.startswith(prefix_dot)
)
]
def pin_net(self, ref: str, pin_number: str) -> str | None:
"""Net name for a specific pin on a component."""
comp = self.components.get(ref)
if not comp:
return None
return comp.pins.get(pin_number)
# ---------------------------------------------------------------------------
# Validation report models
# ---------------------------------------------------------------------------
class Finding(BaseModel):
"""A single review finding — an issue found during direct datasheet review."""
finding_id: str | None = None
designator: str
mpn: str = ""
aspect: str | None = None # "power_supply", "clock", etc. (for complex ICs)
finding: str # What was observed in the actual circuit
why: str = "" # Why it matters — from the datasheet
source_page: int | None = None # Datasheet page (null for deterministic checks)
source_quote: str = "" # Verbatim datasheet text supporting the finding (for PDF highlight)
source_designator: str | None = None # Designator whose datasheet source_page/source_quote refer to; None = this finding's own `designator`. Set when the evidence came from a connected component's datasheet excerpt (get_datasheet_excerpt), so the viewer opens the right PDF at the right page.
status: Literal["ERROR", "WARNING", "INFO"]
recommendation: str = ""
reference: str = ""
source: str | None = None # None/"review" = LLM datasheet review; "pin_mux_check"/"led_current_check" = deterministic
class ValidationReport(BaseModel):
"""Full validation output."""
project: str
timestamp: str
findings: list[Finding]
summary: dict[str, int]
coverage: dict[str, list[str]] = {} # designator -> areas checked and found OK
review_errors: dict[str, str] = {} # designator -> error message for ICs whose review raised
not_reviewed: list[dict] = [] # [{"designator","reason"}] — ICs skipped (e.g. no datasheet PDF)
class FindingComment(BaseModel):
"""A comment on a finding, stored outside the ValidationReport model."""
comment_id: str
finding_id: str
user_id: str
user_name: str
text: str
mentions: list[str] = []
created_at: str
# ---------------------------------------------------------------------------
# Passive component pattern models
# ---------------------------------------------------------------------------
class PassiveFieldDef(BaseModel):
"""One named field in a passive component part number."""
name: str
position: int
length: int
description: str
lookup: dict[str, str] = {}
class ValueDecoder(BaseModel):
"""How to decode the value field (resistance/capacitance) into a number.
letter_multipliers maps characters to power-of-10 exponents (int) or the
special string ``"decimal_point"`` for R-notation (e.g. 4R7 = 4.7 ohms).
"""
type: str # "eia3_pf" | "eia4_ohm_conditional"
base_unit: str # "pF" | "ohm"
output_unit: str # "F" | "ohm"
letter_multipliers: dict[str, int | str] = {}
zero_code: str | None = None
conditional_on: dict | None = None
class PassivePattern(BaseModel):
"""Regex pattern + field decoders for a passive component family."""
manufacturer: str
series: str
component_type: ComponentType
component_subtype: str | None = None # dotted taxonomy path, e.g. "passive.capacitor.ceramic"
description: str
regex: str
fields: list[PassiveFieldDef]
value_decoder: ValueDecoder
example_mpns: list[str] = []
datasheet_key: str | None = None # library storage key for shared datasheet PDF
_validate_subtype = field_validator("component_subtype", mode="before")(
staticmethod(_check_subtype)
)
class ResolvedPassive(BaseModel):
"""Result of resolving a BOM MPN against a stored pattern."""
mpn: str
references: list[str]
component_type: ComponentType
component_subtype: str | None = None # dotted taxonomy path, e.g. "passive.resistor"
_validate_subtype = field_validator("component_subtype", mode="before")(
staticmethod(_check_subtype)
)
manufacturer: str
series: str
value: float
value_formatted: str
tolerance: str | None = None
package: str | None = None
voltage_rating: str | None = None
power_rating: str | None = None
dielectric: str | None = None
raw_fields: dict[str, str] = {}
+280
View File
@@ -0,0 +1,280 @@
"""Pure parsers for PADS-PCB netlists and KiCad BOM CSV files."""
from __future__ import annotations
import csv
from pathlib import Path
from typing import Literal
NetlistFormat = Literal["pads", "edif"]
def parse_netlist(
path: str | Path,
known_refs: set[str] | None = None,
) -> tuple[dict[str, str], dict[str, list[tuple[str, str]]]]:
"""Parse a PADS-PCB ASCII netlist (.asc).
PADS-PCB allows reference designators containing spaces (e.g. ``CV GND``,
``CAN BUS IN``, ``3.3V ACTIVE``). When ``known_refs`` is supplied (typically
from the BOM), tokens are greedily matched to the longest known designator
so multi-word refs parse correctly. Without ``known_refs`` the parser falls
back to single-word tokenisation.
Returns:
parts: {reference: footprint}
nets: {net_name: [(component_ref, pin_number), ...]}
"""
text = Path(path).read_text()
lines = text.splitlines()
parts: dict[str, str] = {}
nets: dict[str, list[tuple[str, str]]] = {}
section = None
current_net: str | None = None
for raw_line in lines:
line = raw_line.strip()
if not line:
continue
# Section markers. PADS-PCB headers may carry trailing labels
# (e.g. "*PART* ITEMS" or "*MISC* MISCELLANEOUS PARAMETERS"
# from EasyEDA Pro), so match the marker prefix rather than the whole
# line. Unknown markers (anything starred that we don't recognise) are
# treated as section terminators — without this, EasyEDA Pro's *MISC*
# ATTRIBUTE VALUES block leaks into the net section and "Datasheet"
# URLs / footprint strings get misparsed as pin connections.
if line.startswith("*"):
if line.startswith("*SIGNAL*"):
pass # sub-marker within *NET*; handled in the net branch
elif line.startswith("*PART*"):
section = "part"
current_net = None
continue
elif line.startswith("*NET*"):
section = "net"
current_net = None
continue
elif line.startswith("*END*"):
break
else:
# *PADS-PCB*, *REMARK*, *MISC*, or any unrecognised marker
section = None
current_net = None
continue
if section == "part":
tokens = line.split()
ref, footprint = _parse_part_tokens(tokens, known_refs)
if ref:
parts[ref] = footprint
elif section == "net":
if line.startswith("*SIGNAL*"):
current_net = line.split("*SIGNAL*", 1)[1].strip()
if current_net not in nets:
nets[current_net] = []
elif current_net is not None:
# Pin entries: "REF.PIN REF.PIN ..." (REF may contain spaces)
nets[current_net].extend(_parse_pin_tokens(line.split(), known_refs))
# Some PADS-PCB exports omit the *PART* section entirely and ship only
# connectivity. Synthesize parts from refs seen in *SIGNAL* blocks so
# downstream validation and graph-building still work; footprints stay
# empty (the BOM is the source of truth for footprints anyway).
if not parts and nets:
for pins in nets.values():
for ref, _pin in pins:
parts.setdefault(ref, "")
return parts, nets
def _parse_part_tokens(
tokens: list[str],
known_refs: set[str] | None,
) -> tuple[str | None, str]:
"""Split a *PART* line into (ref, footprint), respecting multi-word refs."""
if not tokens:
return None, ""
if known_refs:
# Greedy longest-prefix match against known refs
for n in range(min(len(tokens), 8), 0, -1):
candidate = " ".join(tokens[:n])
if candidate in known_refs:
return candidate, " ".join(tokens[n:])
# Fallback: single-word ref, rest is footprint
if len(tokens) >= 2:
return tokens[0], " ".join(tokens[1:])
return tokens[0], ""
def _parse_pin_tokens(
tokens: list[str],
known_refs: set[str] | None,
) -> list[tuple[str, str]]:
"""Parse a *SIGNAL* pin line into (ref, pin) pairs.
Tokens terminate on a ``.`` — everything before (back to the previous
consumed position) is the ref, possibly with internal spaces.
"""
pins: list[tuple[str, str]] = []
consumed = -1
for j, token in enumerate(tokens):
if j <= consumed or "." not in token:
continue
last_word, pin = token.rsplit(".", 1)
# Greedy longest match when known_refs is available
if known_refs:
matched_start: int | None = None
for start in range(consumed + 1, j + 1):
parts = tokens[start:j] + ([last_word] if last_word else [])
candidate = " ".join(parts)
if candidate and candidate in known_refs:
matched_start = start
break
if matched_start is not None:
ref = " ".join(
tokens[matched_start:j] + ([last_word] if last_word else [])
)
pins.append((ref, pin))
consumed = j
continue
# Fallback: single-word ref (original behaviour)
ref = last_word
pins.append((ref, pin))
consumed = j
return pins
def detect_netlist_format(content: bytes | str) -> NetlistFormat:
"""Sniff the first chunk of a netlist to decide whether it's PADS or EDIF.
EDIF s-expressions start with ``(edif …`` (with possible leading whitespace
or BOM); PADS-PCB ASCII files start with ``*PADS-PCB*``. The "pads" branch
is the default when no clear marker is found — preserves the old behavior
where the parser raises a friendly error on unrecognised input.
"""
if isinstance(content, bytes):
try:
text = content[:1024].decode("utf-8", errors="replace")
except Exception:
text = ""
else:
text = content[:1024]
head = text.lstrip("").lstrip()
# Case-insensitive match — EDIF spec allows different capitalisations
# (KiCad emits lowercase; xDX Designer emits lowercase too).
if head[:5].lower() == "(edif":
return "edif"
return "pads"
def parse_netlist_any(
path: str | Path,
known_refs: set[str] | None = None,
*,
include_subdesigns: set[str] | None = None,
) -> tuple[dict[str, str], dict[str, list[tuple[str, str]]], NetlistFormat]:
"""Auto-detect the netlist format and parse.
Returns ``(parts, nets, format)``. The ``parts`` and ``nets`` shapes match
:func:`parse_netlist`; downstream code (graph build, validation) doesn't
need to know which parser ran. ``known_refs`` is only relevant for PADS —
EDIF designators are unambiguous tokens. ``include_subdesigns`` is only
relevant for EDIF — it filters which ``&NNNN``-prefixed instances and
their nets land in the output (PADS netlists have no sub-design concept).
"""
p = Path(path)
sample = p.read_bytes()[:1024]
fmt = detect_netlist_format(sample)
if fmt == "edif":
from backend.pinscopex.parsers_edif import parse_edif_netlist
parts, nets = parse_edif_netlist(p, include_subdesigns=include_subdesigns)
else:
parts, nets = parse_netlist(p, known_refs=known_refs)
return parts, nets, fmt
def validate_netlist(parts: dict, nets: dict) -> list[str]:
"""Sanity-check parsed netlist data. Returns a list of error strings (empty = valid)."""
errors: list[str] = []
if not parts:
errors.append("No components found — is this a PADS-PCB (.asc) or EDIF (.edn) netlist?")
return errors # further checks are meaningless without parts
if not nets:
errors.append("No nets found — the connectivity section (*NET*) is missing or empty")
return errors
# At least some parts must appear in the net connections
refs_in_nets = {ref for pins in nets.values() for ref, _ in pins}
if not (set(parts) & refs_in_nets):
errors.append(
"No components are wired to any net — the connectivity section may be missing or malformed"
)
# Every real schematic has a ground net
gnd_names = {"GND", "AGND", "DGND", "PGND", "VSS", "0V"}
has_gnd = any(
n.upper() in gnd_names or n.upper().endswith("GND") or n.upper().startswith("GND")
for n in nets
)
if not has_gnd:
errors.append(
"No ground net found (expected GND, AGND, DGND, VSS, etc.) — "
"this may not be a complete schematic netlist"
)
return errors
def parse_bom(
path: str | Path,
*,
reference_col: str = "Reference",
mpn_col: str = "Manufacturer Part Number",
) -> dict[str, dict]:
"""Parse a KiCad BOM CSV with grouped references.
Args:
path: Path to the BOM CSV file.
reference_col: Column name for reference designators.
mpn_col: Column name for manufacturer part numbers.
Returns:
{reference: {"value": str, "footprint": str, "mpn": str|None, "lcsc": str|None}}
One entry per individual reference (groups are expanded).
"""
result: dict[str, dict] = {}
text = Path(path).read_text()
reader = csv.DictReader(text.splitlines())
for row in reader:
refs_raw = row.get(reference_col, "")
value = row.get("Value", "") or row.get("Comment", "")
footprint = row.get("Footprint", "")
mpn = row.get(mpn_col, "") or None
lcsc = row.get("LCSC", "") or None
# Expand grouped references: "C1,C2,C5" -> ["C1", "C2", "C5"]
for ref in (r.strip() for r in refs_raw.split(",")):
if ref:
result[ref] = {
"value": value,
"footprint": footprint,
"mpn": mpn,
"lcsc": lcsc,
}
return result
+470
View File
@@ -0,0 +1,470 @@
"""Parser for EDIF 2.0.0 netlists (Siemens xDX Designer flavor).
Yields the same ``(parts, nets)`` shape as :func:`parsers.parse_netlist` so
downstream graph building doesn't care which netlist format the user uploaded.
Tested against xDX Designer's exporter. Other EDIF 2.0.0 exporters (OrCAD,
Altium, KiCad, Eagle) will *probably* parse — the s-expression handling is
generic and the EDIF instance/cell/net structure is standardised — but they
have not been verified against real files.
"""
from __future__ import annotations
import re
from pathlib import Path
from typing import Iterator
# ---------------------------------------------------------------------------
# Tokenizer + s-expression parser
# ---------------------------------------------------------------------------
class _Str(str):
"""Marker subclass so quoted-string tokens are distinguishable from atoms.
Both atoms (e.g. ``viewRef``, ``&0441I3151``) and string values
(e.g. ``"U3"``, ``"GROUND"``) end up as Python ``str`` in the parsed
tree. EDIF rarely needs that distinction — string equality compares the
same way — but the marker is here in case future logic does.
"""
def _tokenize(text: str) -> Iterator[object]:
"""Yield tokens: ``'('``, ``')'``, atom :class:`str`, or quoted :class:`_Str`."""
i, n = 0, len(text)
while i < n:
c = text[i]
if c.isspace():
i += 1
continue
if c == ";":
# EDIF doesn't really use comments, but tolerate them just in case
while i < n and text[i] != "\n":
i += 1
continue
if c in "()":
yield c
i += 1
continue
if c == '"':
j = i + 1
buf: list[str] = []
while j < n and text[j] != '"':
if text[j] == "\\" and j + 1 < n:
buf.append(text[j + 1])
j += 2
else:
buf.append(text[j])
j += 1
yield _Str("".join(buf))
i = j + 1
continue
j = i
while j < n and not text[j].isspace() and text[j] not in '()"':
j += 1
yield text[i:j]
i = j
def _parse_sexp(tokens: list[object]) -> list:
"""Build a nested list tree. Atoms / strings remain as ``str`` / ``_Str``."""
it = iter(tokens)
def parse_form() -> list:
result: list = []
for tok in it:
if tok == "(":
result.append(parse_form())
elif tok == ")":
return result
else:
result.append(tok)
return result # unterminated at EOF — return what we have
top: list = []
for tok in it:
if tok == "(":
top.append(parse_form())
elif tok == ")":
raise ValueError("EDIF: unexpected ')' at top level")
else:
top.append(tok)
return top
# ---------------------------------------------------------------------------
# Tree walkers
# ---------------------------------------------------------------------------
def _walk(node: object, head: str) -> Iterator[list]:
"""Yield every nested list whose first element equals ``head``."""
if not isinstance(node, list):
return
if node and isinstance(node[0], str) and node[0] == head:
yield node
for child in node:
if isinstance(child, list):
yield from _walk(child, head)
def _node_id(node: list) -> str | None:
"""Return the identifying atom of ``(<head> <id> ...)``.
Handles ``(<head> (rename &INTERNAL "display") ...)`` by returning
``&INTERNAL`` — the form used elsewhere by ``cellRef`` / ``instanceRef``.
"""
if len(node) < 2:
return None
second = node[1]
if isinstance(second, list) and len(second) >= 2 and second[0] == "rename":
return str(second[1])
if isinstance(second, str):
return str(second)
return None
def _direct_property(node: list, prop_name: str) -> str | None:
"""Return the string value of a ``(property NAME (string "X") ...)`` child.
Only looks at direct children of ``node`` — does not recurse into nested
forms — so it can be called on an ``instance`` without picking up
properties tucked inside ``portInstance`` blocks.
"""
for child in node:
if not (isinstance(child, list) and len(child) >= 2 and child[0] == "property"):
continue
name_node = child[1]
if isinstance(name_node, list) and name_node and name_node[0] == "rename":
actual = str(name_node[1]) if len(name_node) >= 2 else ""
elif isinstance(name_node, str):
actual = str(name_node)
else:
continue
if actual != prop_name:
continue
for elem in child[2:]:
if isinstance(elem, list) and len(elem) >= 2 and elem[0] == "string":
return str(elem[1])
return None
# ---------------------------------------------------------------------------
# Stage extractors
# ---------------------------------------------------------------------------
def _build_cell_library(tree: list) -> dict[tuple[str, str], dict[str, str | None]]:
"""Build ``(library_name, cell_id) -> {port_name: pin_type}``.
``pin_type`` is ``"GROUND"`` (or any other ``Pin_Type`` property value) when
the cell tagged the port; ``None`` when no Pin_Type property is present.
Used to detect which nets are ground.
"""
cells: dict[tuple[str, str], dict[str, str | None]] = {}
for lib in _walk(tree, "library"):
if len(lib) < 2:
continue
lib_name = str(lib[1])
for cell in _walk(lib, "cell"):
cell_id = _node_id(cell)
if not cell_id:
continue
port_map: dict[str, str | None] = {}
for port in _walk(cell, "port"):
if len(port) < 2:
continue
port_name = str(port[1])
port_map[port_name] = _direct_property(port, "Pin_Type")
cells[(lib_name, cell_id)] = port_map
return cells
def _find_cell_ref(node: list) -> tuple[str, str] | None:
"""From an ``(instance ...)`` form, return ``(library_name, cell_id)`` from
its ``(viewRef VIEW (cellRef CELL (libraryRef LIB)))`` triple."""
for child in node:
if not (isinstance(child, list) and child and child[0] == "viewRef"):
continue
for sub in child[1:]:
if isinstance(sub, list) and len(sub) >= 2 and sub[0] == "cellRef":
cell_id = str(sub[1])
lib_name = ""
for sub2 in sub[2:]:
if isinstance(sub2, list) and len(sub2) >= 2 and sub2[0] == "libraryRef":
lib_name = str(sub2[1])
break
return (lib_name, cell_id)
return None
_SUBDESIGN_PREFIX = re.compile(r"^(&\d+)[IN]\d+")
def _subdesign_id(internal_id: str | None) -> str | None:
"""Extract the sub-design prefix from an EDIF instance or net ID.
Siemens xDX Designer emits internal IDs like ``&0441I2234`` (instance) or
``&0441N2250`` (net), where ``&0441`` identifies the sub-design /
schematic view the symbol belongs to. Different sub-designs in one file
get different numeric prefixes; back-annotation, contents, and viewMap
all reuse the same prefix per design.
Returns ``None`` when the ID doesn't match the prefix scheme (bare-named
cells, named nets like ``+5V``, or exports from non-xDX tools). The
parser treats ``None`` as "shared / no sub-design" and includes those
forms in every selection.
"""
if not internal_id:
return None
m = _SUBDESIGN_PREFIX.match(internal_id)
return m.group(1) if m else None
def _build_instance_map(tree: list) -> dict[str, dict]:
"""Walk every ``(instance ...)`` form. Skip back-annotation refs in viewMap.
Each entry: ``{cell_ref, port_pins, inline_designator, footprint, subdesign_id}``.
"""
instances: dict[str, dict] = {}
for inst in _walk(tree, "instance"):
inst_id = _node_id(inst)
if not inst_id:
continue
cell_ref = _find_cell_ref(inst)
port_pins: dict[str, str] = {}
inline_des: str | None = None
for child in inst:
if not isinstance(child, list) or not child:
continue
if child[0] == "portInstance" and len(child) >= 2:
port_name = str(child[1])
for sub in child[2:]:
if isinstance(sub, list) and len(sub) >= 2 and sub[0] == "designator":
port_pins[port_name] = str(sub[1])
break
elif child[0] == "designator" and len(child) >= 2 and inline_des is None:
inline_des = str(child[1])
instances[inst_id] = {
"cell_ref": cell_ref,
"port_pins": port_pins,
"inline_designator": inline_des,
"footprint": _direct_property(inst, "Cell_Name") or "",
"subdesign_id": _subdesign_id(inst_id),
}
return instances
def _build_back_annotation(tree: list) -> dict[str, str]:
"""``instance_id -> real_designator`` from ``viewMap.instanceBackAnnotate``."""
annotations: dict[str, str] = {}
for ann in _walk(tree, "instanceBackAnnotate"):
inst_id: str | None = None
des: str | None = None
for child in ann[1:]:
if not isinstance(child, list) or len(child) < 2:
continue
if child[0] == "instanceRef":
inst_id = str(child[1])
elif child[0] == "designator":
des = str(child[1])
if inst_id and des:
annotations[inst_id] = des
return annotations
def _is_template_designator(des: str) -> bool:
"""xDX exports unconfigured instances with templates like ``R?`` / ``U?``."""
return des.endswith("?")
def _resolve_designators(
instances: dict[str, dict], back_anno: dict[str, str]
) -> dict[str, str]:
"""For each instance, pick the real designator. Drop template-only ones."""
resolved: dict[str, str] = {}
for inst_id, inst in instances.items():
inline = inst["inline_designator"]
annotated = back_anno.get(inst_id)
if inline and not _is_template_designator(inline):
resolved[inst_id] = inline
elif annotated and not _is_template_designator(annotated):
resolved[inst_id] = annotated
# else: unconfigured library symbol — skip
return resolved
def _extract_nets(
tree: list,
instances: dict[str, dict],
designators: dict[str, str],
cell_lib: dict[tuple[str, str], dict[str, str | None]],
include_subdesigns: set[str] | None = None,
) -> dict[str, list[tuple[str, str]]]:
"""Walk every ``(net ...)`` form. Rename ground-touching nets to ``GND``.
When ``include_subdesigns`` is supplied, endpoints belonging to
excluded sub-designs are dropped. A net is kept iff it has at least one
surviving endpoint — bare-named nets (no sub-design prefix) survive as
long as any of their referenced instances does.
"""
nets: dict[str, list[tuple[str, str]]] = {}
for net in _walk(tree, "net"):
if len(net) < 2:
continue
name_node = net[1]
if isinstance(name_node, list) and len(name_node) >= 3 and name_node[0] == "rename":
net_name = str(name_node[2])
elif isinstance(name_node, str):
net_name = str(name_node)
else:
continue
connections: list[tuple[str, str]] = []
touches_ground = False
for child in net[1:]:
if not (isinstance(child, list) and child and child[0] == "joined"):
continue
for ref in child[1:]:
if not (isinstance(ref, list) and len(ref) >= 2 and ref[0] == "portRef"):
continue
port_name = str(ref[1])
inst_id: str | None = None
for sub in ref[2:]:
if isinstance(sub, list) and len(sub) >= 2 and sub[0] == "instanceRef":
inst_id = str(sub[1])
break
if not inst_id or inst_id not in instances:
continue
inst = instances[inst_id]
if include_subdesigns is not None:
if inst["subdesign_id"] not in include_subdesigns:
continue
pin = inst["port_pins"].get(port_name)
des = designators.get(inst_id)
if not pin or not des:
continue
if inst["cell_ref"]:
port_map = cell_lib.get(inst["cell_ref"], {})
if port_map.get(port_name) == "GROUND":
touches_ground = True
connections.append((des, pin))
if not connections:
continue
final_name = "GND" if touches_ground else net_name
nets.setdefault(final_name, []).extend(connections)
return nets
# ---------------------------------------------------------------------------
# Public entry point
# ---------------------------------------------------------------------------
def _parse_tree(path: str | Path) -> list:
text = Path(path).read_text(encoding="utf-8", errors="replace")
return _parse_sexp(list(_tokenize(text)))
def parse_edif_netlist(
path: str | Path,
*,
include_subdesigns: set[str] | None = None,
) -> tuple[dict[str, str], dict[str, list[tuple[str, str]]]]:
"""Parse a Siemens xDX Designer EDIF 2.0.0 netlist (``.edn``).
Args:
path: file to parse.
include_subdesigns: when supplied, restrict the output to instances
whose ``&NNNN`` sub-design prefix is in this set. Instances with
no prefix (bare-named cells) are always kept. ``None`` (default)
includes every sub-design — same behavior as before this flag
existed.
Returns:
parts: ``{reference: footprint}`` (footprint from the instance's
``Cell_Name`` property — typically a package size like ``"0402"``)
nets: ``{net_name: [(component_ref, pin_number), ...]}``
Ground nets are renamed to ``"GND"`` based on ``Pin_Type=GROUND`` port
tags in the cell library; if no port tags ground (rare), net names stay
as the EDIF-generated ``$NN…`` strings and downstream validation will
surface the missing ground.
"""
tree = _parse_tree(path)
cell_lib = _build_cell_library(tree)
instances = _build_instance_map(tree)
back_anno = _build_back_annotation(tree)
designators = _resolve_designators(instances, back_anno)
if include_subdesigns is not None:
# Drop excluded instances before nets are walked. Instances with
# subdesign_id=None (bare-named, no prefix) are always kept — they're
# shared between sub-designs in the xDX export and dropping them
# would orphan otherwise-included nets.
designators = {
iid: des
for iid, des in designators.items()
if instances[iid]["subdesign_id"] is None
or instances[iid]["subdesign_id"] in include_subdesigns
}
nets = _extract_nets(
tree, instances, designators, cell_lib,
include_subdesigns=include_subdesigns,
)
parts: dict[str, str] = {}
for inst_id, des in designators.items():
parts[des] = instances[inst_id]["footprint"]
return parts, nets
def list_edif_subdesigns(path: str | Path) -> list[dict]:
"""Return one entry per sub-design found in the file.
Each entry: ``{"id": "&0441", "instance_count": 21,
"designators": ["C1", "C2", ...]}``. Sub-designs are identified by the
``&NNNN`` prefix on EDIF instance IDs; instances with no prefix (bare
cells, rare in xDX exports) are bundled under ``"id": None`` and are
always included regardless of the user's selection.
Designators are sorted naturally (R1 before R10) within each sub-design;
sub-designs themselves are sorted by their first BOM-style designator so
output is deterministic across runs.
"""
tree = _parse_tree(path)
instances = _build_instance_map(tree)
back_anno = _build_back_annotation(tree)
designators = _resolve_designators(instances, back_anno)
by_sub: dict[str | None, list[str]] = {}
for iid, des in designators.items():
sub = instances[iid]["subdesign_id"]
by_sub.setdefault(sub, []).append(des)
def _key(des: str) -> tuple:
# Sort R1 before R10 — split on the first digit run.
head = des.rstrip("0123456789")
tail = des[len(head):]
return (head, int(tail) if tail.isdigit() else 0)
out: list[dict] = []
for sub, dlist in by_sub.items():
dlist.sort(key=_key)
out.append({
"id": sub,
"instance_count": len(dlist),
"designators": dlist,
})
out.sort(key=lambda e: (e["designators"][0] if e["designators"] else "", e["id"] or ""))
return out
+142
View File
@@ -0,0 +1,142 @@
"""Peripheral-function tokens parsed from net names and pin alternate-function
strings.
A *token* is a ``(peripheral, signal)`` pair, e.g. ``("UART5", "TX")`` or
``("I2C1", "SDA")``. Both the schematic net name (user-authored, e.g.
``"MCU-UART5-TX"``) and the datasheet-extracted pin functions (e.g.
``"UART5_RX"``, ``"SPI3_MOSI/I2S3_SDO"``) are reduced to the same canonical
token space so they can be compared.
Used by:
* ``pin_mux_check`` — the deterministic pin-mux feasibility check
* ``validate.build_component_context`` — to render alt-functions only on
peripheral-named-net pins (token-conscious context rendering)
Design goal is *high precision, low recall*: only emit a token when both the
bus family and the signal are unambiguous, so the feasibility check never
false-positives on opaque nets or vocabulary mismatches (CS vs NSS, TXD vs TX).
"""
from __future__ import annotations
import re
# Bus families whose pin assignment is muxed and whose naming is stable enough
# to validate. Longer families that contain a shorter one as a substring
# (FDCAN/CAN, OCTOSPI/QSPI, USART/UART) are listed first; the patterns are
# anchored, so a token like "OCTOSPI1" never matches the bare "SPI" family.
_FAMILIES = (
"LPUART", "USART", "UART", "I2C", "OCTOSPI", "QSPI", "SPI",
"FDCAN", "CAN", "SDMMC", "SDIO", "I2S", "SAI", "USB",
)
_FAMILY_ALT = "|".join(_FAMILIES)
# A single net-name token that is exactly a bus family + optional instance number.
_PERIPHERAL_RE = re.compile(rf"^({_FAMILY_ALT})(\d*)$")
# A pin alternate-function string: <family><instance>_<signal...>.
_FUNCTION_RE = re.compile(rf"^({_FAMILY_ALT})(\d*)_(.+)$")
# Canonical signal names we compare on — restricted to signals with stable
# naming across user net labels and datasheet function strings. SPI's
# controller/peripheral names (PICO/POCI/COPI/CIPO) are NOT canonical — they are
# synonyms of MOSI/MISO (same physical line, renamed) and collapse below.
_SIGNALS = {
"TX", "RX", "SDA", "SCL", "MOSI", "MISO",
"SCK", "NSS", "DP", "DM",
}
# Synonyms collapsed to a canonical signal before comparison.
_SIGNAL_SYNONYMS = {
"TXD": "TX", "RXD": "RX",
"SCLK": "SCK", "CLK": "SCK",
"SS": "NSS", "CS": "NSS", "NCS": "NSS", "STE": "NSS",
"DPLUS": "DP", "DMINUS": "DM",
# SPI controller/peripheral nomenclature — the same physical lines as
# master/slave MOSI/MISO, just renamed (TI/NXP/ST modern parts). A net
# labelled SPI0_MOSI landing on a pin whose datasheet function is SPI0_PICO
# is feasible, not a defect. (SDO/SDI deliberately omitted — their meaning
# flips with controller-vs-peripheral perspective, so they aren't safe to
# equate here.)
"PICO": "MOSI", "COPI": "MOSI",
"POCI": "MISO", "CIPO": "MISO",
}
# Directional complements — the signal that *should* be present if the asserted
# one isn't. Used to phrase a feasibility finding as a likely swap. Keyed on
# canonical signals only (PICO/POCI collapse to MOSI/MISO before this is read).
_COMPLEMENT = {
"TX": "RX", "RX": "TX",
"SDA": "SCL", "SCL": "SDA",
"MOSI": "MISO", "MISO": "MOSI",
"DP": "DM", "DM": "DP",
}
# Chip-select alternates often carry an instance suffix (SPI0_CS0..CS3, STE0..);
# strip the trailing index so every variant canonicalises to the bare CS token.
_CHIP_SELECT_INDEXED_RE = re.compile(r"^(N?CS|SS|STE)\d+$")
def _canon_signal(tok: str) -> str | None:
"""Canonicalise a raw signal token, or return None if it isn't a known signal."""
t = tok.upper()
m = _CHIP_SELECT_INDEXED_RE.match(t)
if m:
t = m.group(1)
t = _SIGNAL_SYNONYMS.get(t, t)
return t if t in _SIGNALS else None
def _tokens(name: str) -> list[str]:
"""Split a net name into delimiter-separated tokens (uppercased)."""
s = name.upper().lstrip("/")
# Map the only signals that embed a delimiter char before splitting.
s = s.replace("D+", "DP").replace("D-", "DM")
s = re.sub(r"[._/]", "-", s)
return [p for p in s.split("-") if p]
def parse_net_token(net_name: str) -> tuple[str, str] | None:
"""Extract a ``(peripheral, canonical_signal)`` token from a net name, or None.
Emits only when a bus-family token is immediately followed by a known
signal, e.g. ``"MCU-UART5-TX" -> ("UART5", "TX")``,
``"I2C1-SDA-3V3" -> ("I2C1", "SDA")``. Opaque nets (``"NetC7_1"``,
``"MCU-RESET"``) return None.
"""
parts = _tokens(net_name)
for i in range(len(parts) - 1):
m = _PERIPHERAL_RE.match(parts[i])
if not m:
continue
sig = _canon_signal(parts[i + 1])
if sig is None:
continue
return (m.group(1) + m.group(2), sig)
return None
def normalize_functions(functions: list[str] | None) -> set[tuple[str, str]]:
"""Reduce a pin's alternate-function strings to canonical
``(peripheral, signal)`` tokens. Splits slash-joined alternates
(``"SPI3_MOSI/I2S3_SDO"`` -> two tokens)."""
out: set[tuple[str, str]] = set()
for f in functions or []:
for alt in f.upper().replace("D+", "DP").replace("D-", "DM").split("/"):
m = _FUNCTION_RE.match(alt.strip())
if not m:
continue
sig = _canon_signal(m.group(3))
if sig is None:
continue
out.add((m.group(1) + m.group(2), sig))
return out
def signals_for_peripheral(funcs: set[tuple[str, str]], peripheral: str) -> set[str]:
"""All canonical signals a function set exposes for one peripheral instance."""
return {s for (p, s) in funcs if p == peripheral}
def complement(signal: str) -> str | None:
"""The directional complement of a signal (TX<->RX, SDA<->SCL, ...), or None."""
return _COMPLEMENT.get(signal)
+172
View File
@@ -0,0 +1,172 @@
"""Deterministic pin-mux feasibility check.
For each IC pin whose net name asserts a peripheral function (e.g. a net named
``MCU-UART5-TX`` asserts ``UART5_TX``), verify that the pin can actually be
configured for that function per the datasheet alternate-function table. A pin
that exposes peripheral P but *not* the asserted signal S (e.g. PD2 exposes
UART5 only as ``UART5_RX``) cannot be muxed to S — a hard, context-free defect.
This is a FEASIBILITY check, never a DIRECTION check. It makes no claim about
whether a TX should connect to a peer's RX (direct-UART crossover) or TX
(transceiver/isolator straight-through) — that is context-dependent and left to
the agentic reviewer. To stay sound it SKIPS any net that also lands on another
IC exposing the same peripheral (an inter-device link, where the net name's
perspective is ambiguous).
"""
from __future__ import annotations
from backend.pinscopex.models import (
ComponentConstraints,
ComponentType,
DesignGraph,
Finding,
)
from backend.pinscopex.pin_function_tokens import (
complement,
normalize_functions,
parse_net_token,
signals_for_peripheral,
)
from backend.pinscopex.validate import _match_constraints
def check_pin_mux_feasibility(
graph: DesignGraph,
constraints_map: dict[str, ComponentConstraints],
) -> list[Finding]:
"""Flag IC pins assigned a peripheral function their silicon can't route."""
findings: list[Finding] = []
for ref, comp in sorted(graph.components.items()):
if comp.component_type != ComponentType.IC:
continue
cons = _match_constraints(comp.mpn or comp.value, constraints_map)
if not cons:
continue
for pin_num, net_name in comp.pins.items():
token = parse_net_token(net_name)
if token is None:
continue
peripheral, signal = token
pin = cons.pin_by_number(pin_num)
if pin is None or not pin.functions:
continue
exposed = signals_for_peripheral(
normalize_functions(pin.functions), peripheral
)
if not exposed:
continue # pin doesn't expose this peripheral at all — not our case
if signal in exposed:
continue # feasible; any direction question is the reviewer's call
# Pin exposes the peripheral but NOT the asserted signal -> infeasible.
# Gate: skip if another IC pin on this net also exposes the peripheral
# (inter-device same-peripheral link — could be a legitimate crossover
# or transceiver straight-through; leave it to the agentic reviewer).
if _peer_exposes_peripheral(
graph, constraints_map, net_name, ref, peripheral
):
continue
findings.append(
_feasibility_finding(
ref, comp.mpn or "", pin_num, pin.name,
net_name, peripheral, signal, exposed, pin.functions,
)
)
return findings
def _peer_exposes_peripheral(
graph: DesignGraph,
constraints_map: dict[str, ComponentConstraints],
net_name: str,
self_ref: str,
peripheral: str,
) -> bool:
"""True if any *other* IC pin on this net exposes the given peripheral."""
net = graph.nets.get(net_name)
if not net:
return False
for pc in net.pins:
if pc.component_ref == self_ref:
continue
other = graph.components.get(pc.component_ref)
if not other or other.component_type != ComponentType.IC:
continue
ocons = _match_constraints(other.mpn or other.value, constraints_map)
if not ocons:
continue
opin = ocons.pin_by_number(pc.pin_number)
if opin is None or not opin.functions:
continue
if signals_for_peripheral(normalize_functions(opin.functions), peripheral):
return True
return False
def _feasibility_finding(
ref: str,
mpn: str,
pin_num: str,
pin_name: str,
net_name: str,
peripheral: str,
signal: str,
exposed: set[str],
functions: list[str],
) -> Finding:
# Full alternate-function list, verbatim from the datasheet and in datasheet
# order — NOT our canonicalized tokens. Printing the raw strings keeps the
# finding self-auditing: a reader (or a future us) can spot a naming synonym
# we haven't taught the tokenizer yet (this is how the SPI PICO/POCI==MOSI/MISO
# false positive slipped through — the finding only showed the derived subset).
functions_str = ", ".join(functions) if functions else "(none listed)"
comp_sig = complement(signal)
is_swap = bool(comp_sig and comp_sig in exposed)
swap_hint = ""
rec = (
f"Move '{net_name}' to a pin whose alternate functions include "
f"{peripheral}_{signal}."
)
if is_swap:
swap_hint = (
f" This pin's {peripheral} role is {peripheral}_{comp_sig} — the "
f"complement of {peripheral}_{signal} — so the {signal}/{comp_sig} "
f"nets are most likely swapped."
)
rec = (
f"Move '{net_name}' to a {peripheral}_{signal}-capable pin, or swap "
f"it with the paired {peripheral}_{comp_sig} net if that resolves both."
)
return Finding(
designator=ref,
mpn=mpn,
aspect="pin_mux",
source="pin_mux_check",
source_page=None,
status="ERROR",
finding=(
f"Net '{net_name}' assigns {ref} pin {pin_num} ({pin_name}) the "
f"{peripheral}_{signal} function, but this pin cannot be muxed as "
f"{peripheral}_{signal}."
),
why=(
f"The intended function {peripheral}_{signal} was inferred from the "
f"net name '{net_name}'. Per the datasheet alternate-function table, "
f"pin {pin_num} ({pin_name}) can be muxed as: {functions_str}. "
f"{peripheral}_{signal} is not in that list, so the silicon cannot "
f"route it here regardless of downstream wiring." + swap_hint +
f" If '{net_name}' is not actually configured for {peripheral} in "
f"firmware (e.g. bit-banged GPIO, or a label carried over from the "
f"connected part), disregard this finding."
),
recommendation=rec,
reference=f"{mpn or ref} alternate-function table",
)
+554
View File
@@ -0,0 +1,554 @@
"""Resolve passive component MPNs against stored manufacturer patterns."""
from __future__ import annotations
import argparse
import json
import re
from collections import defaultdict
from pathlib import Path
from backend.pinscopex.models import (
CapacitorSpecs,
ComponentSpecs,
ComponentType,
InductorSpecs,
PassivePattern,
ResistorSpecs,
ResolvedPassive,
SimpleComponentSpecs,
ValueDecoder,
)
from backend.pinscopex.parsers import parse_bom
# ---------------------------------------------------------------------------
# Value decoders
# ---------------------------------------------------------------------------
def _multiplier(digit: str, letter_multipliers: dict[str, int | str]) -> float:
"""Convert a multiplier character to its power-of-10 value.
Raises ValueError for ``"decimal_point"`` entries — callers must handle
R-notation before reaching here.
"""
if digit in letter_multipliers:
val = letter_multipliers[digit]
if val == "decimal_point":
raise ValueError(f"Letter '{digit}' is a decimal-point marker, not a multiplier")
return 10.0 ** int(val)
return 10.0 ** int(digit)
def _decode_eia3_pf(digits: str) -> float:
"""3-digit EIA code → picofarads. e.g. '106' → 10×10^6 = 10_000_000 pF."""
sig = int(digits[:2])
mult = int(digits[2])
return float(sig) * (10.0 ** mult)
def _decode_r_notation(digits: str, decimal_letters: set[str]) -> float | None:
"""Try to decode R-notation (e.g. '4R70' → 4.70, '47R0' → 47.0).
Returns None if no decimal-point letter is found in *digits*.
"""
for letter in decimal_letters:
if letter in digits:
return float(digits.replace(letter, "."))
return None
def _decode_eia4_ohm(
digits: str,
tolerance_code: str,
decoder: ValueDecoder,
) -> float:
"""4-digit resistance code → ohms, with tolerance-conditional layout."""
if decoder.zero_code and digits == decoder.zero_code:
return 0.0
# Handle R-notation: letters marked as "decimal_point" in letter_multipliers
decimal_letters = {
k for k, v in decoder.letter_multipliers.items() if v == "decimal_point"
}
if decimal_letters:
r_val = _decode_r_notation(digits, decimal_letters)
if r_val is not None:
return r_val
cond = decoder.conditional_on or {}
high_tol = cond.get("high_tolerance", [])
if tolerance_code in high_tol:
layout = cond.get("high_tolerance_layout", {})
else:
layout = cond.get("low_tolerance_layout", {})
sig_start = layout.get("significant_start", 0)
sig_count = layout.get("significant_count", 3)
mult_idx = layout.get("multiplier_index", 3)
sig = int(digits[sig_start : sig_start + sig_count])
mult_char = digits[mult_idx]
return float(sig) * _multiplier(mult_char, decoder.letter_multipliers)
def _decode_letter_decimal(digits: str, decoder: ValueDecoder) -> float:
"""Letter-decimal notation: letter serves as decimal point AND multiplier.
Examples (resistor): 2K2→2200Ω, 97R6→97.6Ω, 10K→10000Ω, 1M→1MΩ
"""
for letter, mult in decoder.letter_multipliers.items():
if letter in digits:
before, after = digits.split(letter, 1)
if after:
value = float(f"{before}.{after}")
else:
value = float(before)
return value * float(mult)
# No letter found — pure numeric
return float(digits)
def decode_value(
digits: str,
decoder: ValueDecoder,
tolerance_code: str | None = None,
) -> float:
"""Dispatch to the correct decoder and convert to output_unit."""
if decoder.type == "eia3_pf":
pf = _decode_eia3_pf(digits)
if decoder.output_unit == "F":
return pf * 1e-12
return pf
if decoder.type == "eia4_ohm_conditional":
return _decode_eia4_ohm(digits, tolerance_code or "", decoder)
if decoder.type == "letter_decimal_ohm":
return _decode_letter_decimal(digits, decoder)
raise ValueError(f"Unknown decoder type: {decoder.type}")
# ---------------------------------------------------------------------------
# Value formatting
# ---------------------------------------------------------------------------
_SI_PREFIXES_OHM = [
(1e6, "Mohm"),
(1e3, "kohm"),
(1.0, "ohm"),
(1e-3, "mohm"),
]
_SI_PREFIXES_F = [
(1e-3, "mF"),
(1e-6, "uF"),
(1e-9, "nF"),
(1e-12, "pF"),
(1e-15, "fF"),
]
def _format_value(value: float, unit: str) -> str:
"""Format a value with appropriate SI prefix."""
if value == 0.0:
return f"0 {unit}"
prefixes = _SI_PREFIXES_OHM if unit == "ohm" else _SI_PREFIXES_F
for threshold, label in prefixes:
if abs(value) >= threshold * 0.999:
scaled = value / threshold
# Prefer integer display when possible
if scaled == int(scaled):
return f"{int(scaled)} {label}"
# Up to 2 decimal places, strip trailing zeros
return f"{scaled:.2f}".rstrip("0").rstrip(".") + f" {label}"
# Fallback
return f"{value} {unit}"
def _parse_wattage(s: str) -> str:
"""Pass through wattage string as-is (e.g. '1/10W')."""
return s
# ---------------------------------------------------------------------------
# ResolvedPassive → ComponentSpecs converter
# ---------------------------------------------------------------------------
def resolved_to_specs(resolved: ResolvedPassive) -> ComponentSpecs:
"""Convert a ResolvedPassive to its type-specific specs model."""
if resolved.component_type == ComponentType.RESISTOR:
return ResistorSpecs(
value_ohms=resolved.value,
value_formatted=resolved.value_formatted,
tolerance=resolved.tolerance,
package=resolved.package,
power_rating_w=resolved.power_rating,
)
if resolved.component_type == ComponentType.CAPACITOR:
return CapacitorSpecs(
value_farads=resolved.value,
value_formatted=resolved.value_formatted,
tolerance=resolved.tolerance,
package=resolved.package,
voltage_rating_v=resolved.voltage_rating,
dielectric=resolved.dielectric,
)
if resolved.component_type == ComponentType.INDUCTOR:
return InductorSpecs(
value_henries=resolved.value,
value_formatted=resolved.value_formatted,
tolerance=resolved.tolerance,
package=resolved.package,
)
raise ValueError(f"Unsupported component type: {resolved.component_type}")
# ---------------------------------------------------------------------------
# SimpleComponentSpecs → typed passive specs (for DigiKey auto-resolve)
# ---------------------------------------------------------------------------
_SPICE_MULTIPLIERS: dict[str, float] = {
"T": 1e12, "G": 1e9, "M": 1e6, "k": 1e3,
"m": 1e-3, "u": 1e-6, "n": 1e-9, "p": 1e-12,
}
_UNIT_SUFFIXES = ("ohm", "F", "H", "V", "W", "A", "Hz")
def _parse_spice_value(s: str) -> float:
"""Parse a SPICE-prefixed value string to a float.
Examples: "5.1kohm" → 5100.0, "470nF" → 4.7e-7, "30V" → 30.0,
"120 at 100MHz" → 120.0
"""
s = s.strip()
# Strip conditional clauses like "at 100MHz" or "@ 100MHz"
for sep in (" at ", " @ ", "@"):
idx = s.find(sep)
if idx > 0:
s = s[:idx].strip()
break
# Strip unit suffix
for suffix in _UNIT_SUFFIXES:
if s.endswith(suffix):
s = s[: -len(suffix)]
break
# Try direct float (no multiplier)
try:
return float(s)
except ValueError:
pass
# Find multiplier character (last non-digit, non-dot char)
for i in range(len(s) - 1, -1, -1):
ch = s[i]
if ch in _SPICE_MULTIPLIERS:
numeric = s[:i] + s[i + 1 :]
return float(numeric) * _SPICE_MULTIPLIERS[ch]
raise ValueError(f"Cannot parse SPICE value: {s!r}")
def simple_to_typed_passive_specs(simple: SimpleComponentSpecs) -> ComponentSpecs:
"""Convert auto-resolved SimpleComponentSpecs to a typed passive model."""
subtype = simple.component_subtype or ""
vals = simple.values
# Common optional fields
value_formatted = str(vals.get("value_formatted") or "")
tolerance = str(vals.get("tolerance")) if vals.get("tolerance") else None
package = str(vals.get("package")) if vals.get("package") else None
subtype_for_specs = subtype or None
if subtype.startswith("passive.resistor") or subtype == "passive.resistor":
raw = vals.get("value_ohms")
if raw is None:
raise ValueError(f"Missing value_ohms in auto-resolved resistor specs")
value_ohms = _parse_spice_value(str(raw)) if isinstance(raw, str) else float(raw)
power_rating_w = str(vals.get("power_rating_w")) if vals.get("power_rating_w") else None
return ResistorSpecs(
component_subtype=subtype_for_specs,
value_ohms=value_ohms,
value_formatted=value_formatted or _format_value(value_ohms, "ohm"),
tolerance=tolerance,
package=package,
power_rating_w=power_rating_w,
)
if subtype.startswith("passive.capacitor"):
raw = vals.get("value_farads")
if raw is None:
raise ValueError(f"Missing value_farads in auto-resolved capacitor specs")
value_farads = _parse_spice_value(str(raw)) if isinstance(raw, str) else float(raw)
voltage_rating_v = str(vals.get("voltage_rating_v")) if vals.get("voltage_rating_v") else None
dielectric = str(vals.get("dielectric")) if vals.get("dielectric") else None
return CapacitorSpecs(
component_subtype=subtype_for_specs,
value_farads=value_farads,
value_formatted=value_formatted or _format_value(value_farads, "F"),
tolerance=tolerance,
package=package,
voltage_rating_v=voltage_rating_v,
dielectric=dielectric,
)
if subtype.startswith("passive.inductor") or subtype == "passive.ferrite_bead":
raw = vals.get("value_henries")
if raw is None:
raise ValueError(f"Missing value_henries in auto-resolved inductor specs")
value_henries = _parse_spice_value(str(raw)) if isinstance(raw, str) else float(raw)
current_rating_a = str(vals.get("current_rating_a")) if vals.get("current_rating_a") else None
dcr_raw = vals.get("dcr_ohms")
dcr_ohms: float | None = None
if dcr_raw is not None:
dcr_ohms = _parse_spice_value(str(dcr_raw)) if isinstance(dcr_raw, str) else float(dcr_raw)
return InductorSpecs(
component_subtype=subtype_for_specs,
value_henries=value_henries,
value_formatted=value_formatted,
tolerance=tolerance,
package=package,
current_rating_a=current_rating_a,
dcr_ohms=dcr_ohms,
)
raise ValueError(f"Unsupported passive subtype for conversion: {subtype!r}")
# ---------------------------------------------------------------------------
# Pattern loading and matching
# ---------------------------------------------------------------------------
class SkippedItem:
"""A component or pattern that was skipped due to an error."""
__slots__ = ("identifier", "stage", "error")
def __init__(self, identifier: str, stage: str, error: str) -> None:
self.identifier = identifier
self.stage = stage
self.error = error
def to_dict(self) -> dict[str, str]:
return {"identifier": self.identifier, "stage": self.stage, "error": self.error}
def load_patterns(
patterns_dir: str | Path,
skipped: list[SkippedItem] | None = None,
) -> list[PassivePattern]:
"""Load all pattern JSON files from a directory.
Invalid pattern files are silently skipped (appended to *skipped* if provided).
"""
patterns_dir = Path(patterns_dir)
patterns: list[PassivePattern] = []
for f in sorted(patterns_dir.glob("*.json")):
try:
data = json.loads(f.read_text())
patterns.append(PassivePattern(**data))
except Exception as e:
if skipped is not None:
skipped.append(SkippedItem(f.stem, "passive_pattern_load", str(e)))
return patterns
def resolve_mpn(
mpn: str,
patterns: list[PassivePattern],
) -> tuple[PassivePattern, dict[str, str]] | None:
"""Match an MPN against loaded patterns. Returns (pattern, captured_groups) or None."""
for pat in patterns:
m = re.match(pat.regex, mpn)
if m:
return pat, m.groupdict()
return None
# ---------------------------------------------------------------------------
# BOM resolution
# ---------------------------------------------------------------------------
def resolve_bom(
bom_path: str | Path,
patterns_dir: str | Path = "component-patterns",
*,
reference_col: str = "Reference",
mpn_col: str = "Manufacturer Part Number",
skipped: list[SkippedItem] | None = None,
) -> list[ResolvedPassive]:
"""Resolve all passive MPNs in a BOM against stored patterns.
Individual MPNs that fail to decode are silently skipped (appended to
*skipped* if provided).
"""
patterns = load_patterns(patterns_dir, skipped=skipped)
bom = parse_bom(bom_path, reference_col=reference_col, mpn_col=mpn_col)
# Group references by MPN
mpn_refs: dict[str, list[str]] = defaultdict(list)
mpn_value: dict[str, str] = {}
for ref, info in bom.items():
mpn = info.get("mpn")
if mpn:
mpn_refs[mpn].append(ref)
mpn_value[mpn] = info.get("value", "")
resolved: list[ResolvedPassive] = []
for mpn, refs in sorted(mpn_refs.items()):
match = resolve_mpn(mpn, patterns)
if match is None:
continue
try:
pat, groups = match
fields_by_name = {f.name: f for f in pat.fields}
# Decode the primary value — find the value field by name
value_digits = groups.get("resistance") or groups.get("capacitance") or ""
tolerance_code = groups.get("tolerance", "")
value = decode_value(value_digits, pat.value_decoder, tolerance_code)
value_formatted = _format_value(value, pat.value_decoder.output_unit)
# Decode tolerance
tolerance_field = fields_by_name.get("tolerance")
tolerance = (
tolerance_field.lookup.get(tolerance_code) if tolerance_field else None
)
# Decode package size
size_field = fields_by_name.get("size")
size_code = groups.get("size", "")
package = size_field.lookup.get(size_code, size_code) if size_field else None
# Decode voltage rating (capacitors)
voltage_field = fields_by_name.get("voltage")
voltage_code = groups.get("voltage", "")
voltage_rating = (
voltage_field.lookup.get(voltage_code) if voltage_field else None
)
# Decode power rating (resistors)
wattage_field = fields_by_name.get("wattage")
wattage_code = groups.get("wattage", "")
power_rating = (
wattage_field.lookup.get(wattage_code) if wattage_field else None
)
# Decode dielectric (capacitors)
dielectric_field = fields_by_name.get("dielectric")
dielectric_code = groups.get("dielectric", "")
dielectric = (
dielectric_field.lookup.get(dielectric_code)
if dielectric_field
else None
)
# Build raw_fields: code → decoded value for all fields
raw_fields: dict[str, str] = {}
for fname, fval in groups.items():
fd = fields_by_name.get(fname)
if fd and fd.lookup:
raw_fields[fname] = fd.lookup.get(fval, fval)
else:
raw_fields[fname] = fval
resolved.append(
ResolvedPassive(
mpn=mpn,
references=sorted(refs),
component_type=pat.component_type,
component_subtype=pat.component_subtype,
manufacturer=pat.manufacturer,
series=pat.series,
value=value,
value_formatted=value_formatted,
tolerance=tolerance,
package=package,
voltage_rating=voltage_rating,
power_rating=power_rating,
dielectric=dielectric,
raw_fields=raw_fields,
)
)
except Exception as e:
if skipped is not None:
skipped.append(SkippedItem(mpn, "passive_resolve", str(e)))
return resolved
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def main() -> None:
parser = argparse.ArgumentParser(
description="Resolve passive component MPNs from a BOM against stored patterns",
)
parser.add_argument(
"bom",
nargs="?",
default="simple_project/TI-MSP-KICAD9-TUTORIAL.csv",
help="Path to BOM CSV file",
)
parser.add_argument(
"--patterns",
default="component-patterns",
help="Directory containing pattern JSON files",
)
parser.add_argument(
"--output",
default=None,
help="Write resolved JSON to this path",
)
args = parser.parse_args()
resolved = resolve_bom(args.bom, args.patterns)
if not resolved:
print("No passive components resolved.")
return
for r in resolved:
extras = []
if r.tolerance:
extras.append(r.tolerance)
if r.package:
extras.append(r.package)
if r.dielectric:
extras.append(r.dielectric)
if r.voltage_rating:
extras.append(r.voltage_rating)
if r.power_rating:
extras.append(r.power_rating)
extra_str = ", ".join(extras)
print(f" {r.mpn}{r.value_formatted} ({extra_str})")
print(f" refs: {', '.join(r.references)}")
print(f"\nResolved {len(resolved)} passive component(s).")
if args.output:
Path(args.output).write_text(
json.dumps([r.model_dump() for r in resolved], indent=2) + "\n"
)
print(f"Written to {args.output}")
if __name__ == "__main__":
main()
+313
View File
@@ -0,0 +1,313 @@
"""Living component taxonomy: load, query, and grow the subtype tree.
Storage: one JSON file per top-level type in ``taxonomy/``.
Each file is a self-contained document that maps 1:1 to a Firestore
document, so only the relevant branch needs to be fetched/injected
into extraction prompts.
::
taxonomy/
├── ic.json # all IC subtypes
├── passive.json # all passive subtypes
├── discrete.json # diodes, transistors, LEDs
├── connector.json
├── crystal.json
└── ...
"""
from __future__ import annotations
import json
import re
from pathlib import Path
TAXONOMY_DIR = Path(__file__).resolve().parent.parent.parent / "taxonomy"
# Reference-designator prefix -> taxonomy top-level type.
# Used by extraction skills: "I see 'U' so I only need the ic branch."
REF_PREFIX_TO_TYPE: dict[str, str] = {
"U": "ic",
"IC": "ic",
"R": "passive",
"C": "passive",
"L": "passive",
"FB": "passive",
"J": "connector",
"X": "crystal",
"Y": "crystal",
"D": "discrete",
"LED": "discrete",
"Q": "discrete",
"T": "transformer",
"F": "fuse",
"SW": "switch",
"TP": "test_point",
"FM": "fiducial",
"MH": "mechanical",
}
# Canonical format for dotted subtype keys.
SUBTYPE_PATTERN = re.compile(r"^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*$")
# All valid top-level taxonomy types (derived from ref-prefix mapping).
KNOWN_TYPES: frozenset[str] = frozenset(REF_PREFIX_TO_TYPE.values())
def validate_subtype(value: str) -> str:
"""Validate and normalize a component_subtype string.
Lowercases, replaces hyphens/spaces with underscores, then checks
the dotted format and that the top-level segment is a known type.
Returns the normalized value. Raises ``ValueError`` if invalid.
"""
v = value.strip().lower().replace("-", "_").replace(" ", "_")
if not SUBTYPE_PATTERN.match(v):
raise ValueError(
f"Invalid component_subtype format: {value!r}. "
f"Expected dotted lowercase path like 'ic.mcu' or 'passive.resistor'"
)
top = v.split(".")[0]
if top not in KNOWN_TYPES:
raise ValueError(
f"Unknown top-level taxonomy type: {top!r} (from {value!r}). "
f"Known types: {sorted(KNOWN_TYPES)}"
)
return v
def type_for_ref(ref: str) -> str | None:
"""Map a reference designator (e.g. 'U3', 'C12') to a taxonomy type."""
prefix = re.match(r"^[A-Za-z]+", ref)
if not prefix:
return None
return REF_PREFIX_TO_TYPE.get(prefix.group().upper())
# ---------------------------------------------------------------------------
# Loading
# ---------------------------------------------------------------------------
def _load_type_file(top_type: str, directory: Path = TAXONOMY_DIR) -> dict:
"""Load a single type file, returning its raw JSON."""
path = directory / f"{top_type}.json"
if not path.exists():
return {"type": top_type, "subtypes": {}}
return json.loads(path.read_text())
def _save_type_file(top_type: str, data: dict, directory: Path = TAXONOMY_DIR) -> None:
"""Write a type file back to disk."""
directory.mkdir(parents=True, exist_ok=True)
path = directory / f"{top_type}.json"
path.write_text(json.dumps(data, indent=2) + "\n")
def load_subtypes(
top_type: str | None = None,
directory: Path = TAXONOMY_DIR,
) -> dict[str, dict]:
"""Return subtypes as ``{dotted_key: {description, example_mpn?}}``.
If *top_type* is given (e.g. ``"ic"``), only that file is loaded —
keeping prompt injection small. If ``None``, all files are merged.
"""
if top_type is not None:
return dict(_load_type_file(top_type, directory).get("subtypes", {}))
merged: dict[str, dict] = {}
for f in sorted(directory.glob("*.json")):
data = json.loads(f.read_text())
merged.update(data.get("subtypes", {}))
return merged
def list_subtypes(
prefix: str | None = None,
directory: Path = TAXONOMY_DIR,
) -> list[str]:
"""List subtype keys, optionally filtered by dotted prefix.
Efficient: if *prefix* starts with a known top-level type, only that
single file is loaded.
Examples::
list_subtypes() # all subtypes (loads every file)
list_subtypes("ic") # only ic.json loaded
list_subtypes("ic.power") # only ic.json loaded, filtered
list_subtypes("passive") # only passive.json loaded
"""
# Determine which top-level type file to load
top_type: str | None = None
if prefix is not None:
top_type = prefix.split(".")[0]
subtypes = load_subtypes(top_type, directory)
if prefix is None:
return sorted(subtypes.keys())
prefix_dot = prefix if prefix.endswith(".") else prefix + "."
return sorted(k for k in subtypes if k == prefix or k.startswith(prefix_dot))
def get_subtype(key: str, directory: Path = TAXONOMY_DIR) -> dict | None:
"""Get a single subtype entry by its dotted key, or None."""
top_type = key.split(".")[0]
subtypes = load_subtypes(top_type, directory)
return subtypes.get(key)
def set_type_specs(
top_type: str,
specs: list[dict],
directory: Path = TAXONOMY_DIR,
) -> None:
"""Set type-level specs on a taxonomy file."""
data = _load_type_file(top_type, directory)
data["specs"] = specs
_save_type_file(top_type, data, directory)
def set_extra_specs(
subtype_key: str,
extra_specs: list[dict],
directory: Path = TAXONOMY_DIR,
) -> None:
"""Set extra_specs on an existing subtype entry."""
top_type = subtype_key.split(".")[0]
data = _load_type_file(top_type, directory)
subtypes = data.get("subtypes", {})
if subtype_key not in subtypes:
return
subtypes[subtype_key]["extra_specs"] = extra_specs
_save_type_file(top_type, data, directory)
def has_specs(top_type: str, directory: Path = TAXONOMY_DIR) -> bool:
"""Check if a taxonomy type has any specs defined (type-level or extra)."""
data = _load_type_file(top_type, directory)
if data.get("specs"):
return True
for entry in data.get("subtypes", {}).values():
if entry.get("extra_specs"):
return True
return False
def add_subtype(
key: str,
description: str,
example_mpn: str | None = None,
directory: Path = TAXONOMY_DIR,
) -> None:
"""Add a new subtype. Creates the type file if needed. No-op if exists."""
key = validate_subtype(key)
top_type = key.split(".")[0]
data = _load_type_file(top_type, directory)
subtypes = data.setdefault("subtypes", {})
if key in subtypes:
return
entry: dict[str, str] = {"description": description}
if example_mpn:
entry["example_mpn"] = example_mpn
subtypes[key] = entry
data["type"] = top_type
_save_type_file(top_type, data, directory)
def get_specs_schema(
top_type: str,
subtype_key: str | None = None,
directory: Path = TAXONOMY_DIR,
) -> list[dict]:
"""Return merged specs list: type-level ``specs`` + subtype ``extra_specs``."""
data = _load_type_file(top_type, directory)
specs = list(data.get("specs", []))
if subtype_key:
entry = data.get("subtypes", {}).get(subtype_key, {})
specs.extend(entry.get("extra_specs", []))
return specs
def format_specs_for_prompt(top_type: str, directory: Path = TAXONOMY_DIR) -> str:
"""Format type-level + all subtype extra_specs as prompt text.
Includes all possible parameters across subtypes so the extraction
skill knows the full set of fields it might encounter.
"""
data = _load_type_file(top_type, directory)
base_specs = data.get("specs", [])
# Collect all extra_specs across subtypes (deduplicate by name)
all_extra: dict[str, dict] = {}
for entry in data.get("subtypes", {}).values():
for s in entry.get("extra_specs", []):
all_extra[s["name"]] = s
all_specs = list(base_specs) + list(all_extra.values())
if not all_specs:
return ""
lines = [
"PARAMETERS TO EXTRACT (include all that are relevant to this component):",
"",
"Use SPICE multiplier prefixes for values: "
"T=1e12, G=1e9, M=1e6, k=1e3, m=1e-3, u=1e-6, n=1e-9, p=1e-12.",
"Examples: 30V, 240mV, 500mA, 47mohm, 18pF, 8MHz, 10nC.",
"Always include the unit with the multiplier in the value string.",
"",
]
for s in all_specs:
req = " (REQUIRED)" if s.get("required") else ""
unit = f" [{s['unit']}]" if s.get("unit") else ""
lines.append(f"- {s['name']}{unit}: {s['description']}{req}")
return "\n".join(lines)
def format_for_prompt(top_type: str, directory: Path = TAXONOMY_DIR) -> str:
"""Format a type's subtypes as a compact string for LLM prompt injection.
Returns something like::
ic.mcu — Microcontroller (e.g. MSPM0G3507SPTR)
ic.power.ldo — Low-dropout voltage regulator (e.g. SPX3819M5-L-3-3)
ic.power.switching_regulator — Switching voltage regulator (buck, boost, buck-boost)
...
"""
subtypes = load_subtypes(top_type, directory)
lines: list[str] = []
for key in sorted(subtypes):
entry = subtypes[key]
line = f"{key}{entry['description']}"
if "example_mpn" in entry:
line += f" (e.g. {entry['example_mpn']})"
lines.append(line)
return "\n".join(lines)
# ---------------------------------------------------------------------------
# Simple types (taxonomy-driven specs extraction via PDF)
# ---------------------------------------------------------------------------
def _compute_simple_types(directory: Path = TAXONOMY_DIR) -> frozenset[str]:
"""Types that have a ``specs`` schema and use PDF-based extraction.
Excludes ``ic`` (pintable + rules) and ``passive`` (pattern-based).
"""
result: set[str] = set()
if not directory.is_dir():
return frozenset(result)
for f in directory.glob("*.json"):
data = json.loads(f.read_text())
t = data.get("type", "")
if t not in ("ic", "passive") and data.get("specs"):
result.add(t)
return frozenset(result)
SIMPLE_TYPES: frozenset[str] = _compute_simple_types()
+21
View File
@@ -0,0 +1,21 @@
"""Shared utility functions for the pinscopex core library."""
from __future__ import annotations
import re
def safe_mpn(mpn: str) -> str:
"""Sanitize an MPN string for use in filenames and storage keys."""
return mpn.replace("/", "_").replace(":", "_")
def natural_sort_key(s: str) -> tuple:
"""Sort key for natural ordering: R1, R2, R10 (not R1, R10, R2)."""
parts: list[int | str] = []
for chunk in re.split(r"(\d+)", s):
if chunk.isdigit():
parts.append(int(chunk))
else:
parts.append(chunk.lower())
return tuple(parts)
File diff suppressed because it is too large Load Diff
+783
View File
@@ -0,0 +1,783 @@
"""Graph-query tools for direct datasheet review.
Tools let the reviewer trace connections beyond the pre-built
component context. The submit_review tool collects all findings.
"""
from __future__ import annotations
import logging
import re
import tempfile
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from backend.pinscopex.models import (
ComponentConstraints,
DesignGraph,
)
from backend.pinscopex.utils import safe_mpn
log = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _pin_sort_key(pin: str) -> tuple:
m = re.match(r"^(\d+)", pin)
if m:
return (0, int(m.group(1)), pin)
return (1, 0, pin)
_THERMAL_PAD_NAME_RE = re.compile(
r"\b(e[\s\-]?pad|epad|ep|dap|thermal\s*pad|exposed\s*(?:pad|paddle)|die[\s\-]?(?:attach\s*)?pad)\b",
re.IGNORECASE,
)
def _reviewer_voltage_str(net) -> str:
"""Format a net's voltage for reviewer tool output."""
if net is None or net.voltage is None:
return ""
return f", {net.voltage}V"
def _is_thermal_pad_pin(pin) -> bool:
"""Heuristic: does a pintable entry describe the exposed/thermal pad?
Users commonly assign the EP a custom pin number in their schematic
symbol (often pin_count+1) that doesn't match the datasheet pintable's
number for the same pad. Detecting EP pintable entries lets the
reviewer match them to orphan schematic pins instead of reporting them
as unconnected.
"""
for field in (getattr(pin, "name", None), getattr(pin, "description", None)):
if field and _THERMAL_PAD_NAME_RE.search(str(field)):
return True
number = str(getattr(pin, "number", "")).strip()
if number and not number.isdigit() and _THERMAL_PAD_NAME_RE.search(number):
return True
return False
def _format_specs(specs) -> str:
"""Format component specs as a compact string."""
if not specs:
return ""
d = specs.model_dump(exclude_none=True, exclude={"specs_type"})
if not d:
return ""
parts = []
for k, v in d.items():
parts.append(f"{k}={v}")
return ", ".join(parts)
# Type alias for constraints lookup
ConstraintsMap = dict[str, ComponentConstraints] # MPN -> constraints
# ---------------------------------------------------------------------------
# Excerpt tool — per-review state, topic regexes, page selection
# ---------------------------------------------------------------------------
# Each topic maps to a narrow keyword regex used to pick relevant pages from
# a neighbor IC's datasheet. Narrower than _REVIEW_KEYWORDS so an excerpt
# fetch returns a focused slice (~5-10 pages) rather than 30+.
EXCERPT_TOPICS: dict[str, re.Pattern] = {
"absolute_max": re.compile(
r"absolute\s+maximum|maximum\s+ratings?|stress\s+rating",
re.IGNORECASE,
),
"recommended_operating": re.compile(
r"recommended\s+operating|operating\s+conditions?|operating\s+range",
re.IGNORECASE,
),
"electrical_characteristics": re.compile(
r"electrical\s+characteristics?|DC\s+characteristics?|AC\s+characteristics?"
r"|V[IO][HL]\s*\(|input\s+(high|low)\s+voltage|output\s+(high|low)\s+voltage",
re.IGNORECASE,
),
"pin_voltage_levels": re.compile(
r"5[\s\-]?V[\s\-]?tolerant|5V[\s\-]?tolerance|voltage\s+tolerance"
r"|input\s+voltage\s+range|pin\s+voltage|I/O\s+voltage"
r"|V[IO][HL]\b|VIO\b|VDDIO\b|tolerant\s+input",
re.IGNORECASE,
),
"power_supply": re.compile(
r"power\s+supply|supply\s+voltage|VDD|VCC|VBAT|supply\s+current"
r"|quiescent\s+current",
re.IGNORECASE,
),
"thermal": re.compile(
r"thermal\s+(resistance|shutdown|pad|characteristics)|junction\s+temperature"
r"|theta[\s\-]?J[AC]|θJ[AC]",
re.IGNORECASE,
),
"application_circuit": re.compile(
r"application\s+(circuit|schematic|information|note)"
r"|typical\s+application|reference\s+design|recommended\s+circuit",
re.IGNORECASE,
),
}
_EXCERPT_MAX_PAGES_PER_FETCH = 10 # cap per single excerpt call
@dataclass
class ExcerptState:
"""Per-review state threaded through ``execute_tool`` so the excerpt tool
can enforce neighbor-only access, run a fetch/page budget, and reuse
pypdf trim work across ICs in the same validation run.
Created in ``review_ic_async``; carries the cross-IC ``cache`` from the
caller (``validate_design_async``).
"""
current_ic: str
connected_designators: set[str]
graph: DesignGraph
pdf_dir: Path
storage: Any | None = None
# Cross-IC trimmed-PDF cache keyed by (designator, topic, ds_md5)
# -> (trimmed_pdf_path, [original_page_numbers]). Lives for the duration
# of one validate_design_async.
cache: dict[tuple[str, str, str], tuple[str, list[int]]] = field(
default_factory=dict
)
# Per-review budget counters. ``page_budget`` is the global ceiling that
# bounds total fan-out on a hub IC; ``per_neighbor_page_budget`` is a
# sub-budget so that verifying ONE interface (which needs ~2-3 topic
# fetches from a single neighbor — e.g. pin_voltage_levels + absolute_max)
# is never blocked by pages already spent on a *different* neighbor. This
# is the fix for the U2-001 / U3-001 false positives, where a single
# 25-page global budget got exhausted before the abs-max table could be
# read, forcing the reviewer to guess.
fetch_count: int = 0
page_count: int = 0
fetch_budget: int = 8
page_budget: int = 60
per_neighbor_page_budget: int = 30
pages_per_neighbor: dict[str, int] = field(default_factory=dict)
# ---------------------------------------------------------------------------
# Tool implementations
# ---------------------------------------------------------------------------
def find_connected_components(
graph: DesignGraph,
constraints_map: ConstraintsMap,
designator: str,
pin: str,
designator_filter: str | None = None,
) -> str:
"""Find all components on the net at designator.pin, with full specs."""
comp = graph.components.get(designator)
if not comp:
return f"Component '{designator}' not found."
net_name = comp.pins.get(str(pin))
if not net_name:
return f"Pin {pin} on {designator} is not connected in the netlist."
net = graph.nets[net_name]
voltage_str = _reviewer_voltage_str(net)
lines = [f"Net: {net_name} ({net.net_type.value}{voltage_str})"]
count = 0
for pc in net.pins:
if pc.component_ref == designator:
continue
if designator_filter and not pc.component_ref.upper().startswith(designator_filter.upper()):
continue
neighbor = graph.components.get(pc.component_ref)
if not neighbor:
continue
count += 1
# Component header
mpn_str = f", MPN={neighbor.mpn}" if neighbor.mpn else ""
sub_str = f", {neighbor.component_subtype}" if neighbor.component_subtype else ""
specs_str = _format_specs(neighbor.specs)
if specs_str:
specs_str = f" ({specs_str})"
lines.append(
f" {neighbor.reference}: {neighbor.value}{mpn_str}, "
f"{neighbor.component_type.value}{sub_str}{specs_str}"
)
# Pin map
pin_strs = []
for pn, pnet in sorted(neighbor.pins.items(), key=lambda x: _pin_sort_key(x[0])):
pin_strs.append(f"{pn}->{pnet}")
lines.append(f" pins: {', '.join(pin_strs)}")
if count == 0:
filter_note = f" matching '{designator_filter}*'" if designator_filter else ""
lines.append(f" (no components{filter_note} on this net)")
return "\n".join(lines)
def get_net_for_pin(
graph: DesignGraph,
constraints_map: ConstraintsMap,
designator: str,
pin: str,
) -> str:
"""Get net info for a specific pin — lightweight, no component listing."""
comp = graph.components.get(designator)
if not comp:
return f"Component '{designator}' not found."
net_name = comp.pins.get(str(pin))
if not net_name:
return f"Pin {pin} on {designator} is not connected in the netlist."
net = graph.nets[net_name]
voltage_str = _reviewer_voltage_str(net)
# Get pin name from constraints
pin_name = ""
constraints = constraints_map.get(comp.mpn or "")
if constraints:
p = constraints.pin_by_number(pin)
if p:
pin_name = f" ({p.name})"
return f"Pin {pin}{pin_name} on {designator} -> {net_name} [{net.net_type.value}{voltage_str}]"
def get_pintable(
graph: DesignGraph,
constraints_map: ConstraintsMap,
designator: str,
) -> str:
"""Get full pintable with connection status."""
comp = graph.components.get(designator)
if not comp:
return f"Component '{designator}' not found."
constraints = constraints_map.get(comp.mpn or "")
if not constraints:
# Fall back to just showing netlist pins
lines = [f"Pintable for {designator} ({comp.mpn or comp.value}) — no extracted pintable:"]
for pn, pnet in sorted(comp.pins.items(), key=lambda x: _pin_sort_key(x[0])):
net = graph.nets.get(pnet)
ntype = f" [{net.net_type.value}]" if net else ""
lines.append(f" Pin {pn}: -> {pnet}{ntype} [connected]")
return "\n".join(lines)
lines = [f"Pintable for {designator} ({comp.mpn}):"]
matched: set[str] = set()
for p in sorted(constraints.pintable, key=lambda x: _pin_sort_key(str(x.number))):
net_name = comp.pins.get(str(p.number))
func_str = f" [alt: {', '.join(p.functions)}]" if p.functions else ""
if net_name:
matched.add(str(p.number))
net = graph.nets.get(net_name)
voltage_str = _reviewer_voltage_str(net)
ntype = net.net_type.value if net else "?"
lines.append(f" Pin {p.number} ({p.name}): -> {net_name} [{ntype}{voltage_str}]{func_str} [connected]")
else:
tp_note = " [likely exposed pad — check orphan schematic pins below]" if _is_thermal_pad_pin(p) else ""
lines.append(f" Pin {p.number} ({p.name}){func_str}: [unconnected]{tp_note}")
orphans = [pn for pn in comp.pins if pn not in matched]
if orphans:
lines.append("")
lines.append(
"Additional schematic pins (not in datasheet pintable — "
"commonly the EP/thermal pad under a user-chosen pin number):"
)
for pn in sorted(orphans, key=_pin_sort_key):
net_name = comp.pins.get(pn) or ""
net = graph.nets.get(net_name)
voltage_str = _reviewer_voltage_str(net)
ntype = net.net_type.value if net else "?"
lines.append(f" Pin {pn}: -> {net_name} [{ntype}{voltage_str}]")
return "\n".join(lines)
def _resolve_neighbor_pdf(
state: ExcerptState,
mpn: str,
) -> Path | None:
"""Resolve a neighbor IC's MPN to a local PDF path.
Mirrors validation._find_pdf's local-then-library lookup so neighbor
datasheets follow the same resolution rules as the IC under review.
"""
safe = safe_mpn(mpn)
local = state.pdf_dir / f"{safe}.pdf"
if local.is_file():
return local
if state.storage is not None:
try:
from backend.services import projects as proj_svc
lib_key = proj_svc.library_has_datasheet(state.storage, mpn)
if lib_key:
state.storage.download_to_local(lib_key, local)
if local.is_file():
return local
except Exception:
log.exception("excerpt: library lookup failed for %s", mpn)
return None
def _trim_pdf_by_keywords(
pdf_path: Path,
keyword_re: re.Pattern,
max_pages: int,
) -> tuple[str, list[int]]:
"""Pypdf-trim a PDF to pages matching a keyword regex (+/-1 neighbors).
Returns ``(trimmed_pdf_path, kept_page_numbers_1indexed)``. The trimmed
path is a temp file the caller is responsible for cleaning up *eventually*
— in practice we keep these for the lifetime of the validation run so the
same excerpt can be reused across ICs.
Page numbers in the return list are 1-indexed and refer to the *original*
PDF, so the model can cite them as ``source_page`` consistent with the
no-remap convention used everywhere else in the reviewer.
"""
from pypdf import PdfReader, PdfWriter
reader = PdfReader(str(pdf_path))
total = len(reader.pages)
if total == 0:
return str(pdf_path), []
keep: set[int] = set()
for i, page in enumerate(reader.pages):
try:
text = page.extract_text() or ""
except Exception:
text = ""
if keyword_re.search(text):
for n in (i - 1, i, i + 1):
if 0 <= n < total:
keep.add(n)
if len(keep) >= max_pages:
break
if not keep:
# Fall back: first few pages so the model gets *something* it can
# decline to use, rather than an empty excerpt.
keep = set(range(min(3, total)))
selected = sorted(keep)[:max_pages]
writer = PdfWriter()
for i in selected:
writer.add_page(reader.pages[i])
tmp = tempfile.NamedTemporaryFile(suffix=".pdf", delete=False)
writer.write(tmp)
tmp.close()
return tmp.name, [i + 1 for i in selected]
def get_datasheet_excerpt(
graph: DesignGraph,
constraints_map: ConstraintsMap,
designator: str,
topic: str,
state: ExcerptState | None,
):
"""Return pages from a *connected* neighbor IC's datasheet for a topic.
Returns ``(text_summary, pdf_block_or_none)`` — the caller treats the text
as the tool's ``content`` and attaches the PdfBlock (if present) to the
same user message so the model can read the pages on the next turn.
Restricted to neighbors of the IC under review (state.connected_designators).
Subject to per-review fetch/page budget caps.
"""
if state is None:
return ("get_datasheet_excerpt called without per-review state — "
"this is a bug, no excerpt returned.", None)
# Lazy import to avoid backend↔pinscopex circular dependency at module load.
from backend.services.llm import PdfBlock
designator = (designator or "").strip()
topic = (topic or "").strip().lower()
if topic not in EXCERPT_TOPICS:
valid = ", ".join(sorted(EXCERPT_TOPICS.keys()))
return (f"Unknown topic '{topic}'. Valid topics: {valid}.", None)
if designator == state.current_ic:
return (
f"You are already reviewing {designator}'s datasheet — its pages "
f"are in your initial context. Use the existing PDF, no excerpt "
f"fetch needed.",
None,
)
if designator not in state.connected_designators:
return (
f"{designator} is not a signal neighbor of {state.current_ic} "
f"in this design. The excerpt tool is restricted to ICs that "
f"share a signal net with the IC under review. If you suspect "
f"the issue still applies, submit WARNING with an explicit "
f"Unverified: assumption.",
None,
)
comp = graph.components.get(designator)
if comp is None:
return (f"Component '{designator}' not found in design graph.", None)
mpn = comp.mpn or comp.value
if not mpn:
return (f"{designator} has no MPN — cannot resolve a datasheet.", None)
# Budget checks before doing pypdf work. Three caps, in order:
# - fetch_count: total excerpt calls this review (bounds turn cost).
# - per_neighbor_page_budget: pages already pulled from THIS neighbor —
# once a neighbor is fully examined, more pages won't help.
# - page_budget: global ceiling across all neighbors (hub-IC fan-out).
# The per-neighbor cap is checked before the global one so that pulling
# the 2-3 topics needed to verify a single interface is never starved by
# pages spent on other neighbors.
neighbor_pages = state.pages_per_neighbor.get(designator, 0)
if state.fetch_count >= state.fetch_budget:
return (
f"Excerpt budget exhausted ({state.fetch_count}/"
f"{state.fetch_budget} fetches used). Submit WARNING with an "
f"explicit Unverified: assumption rather than fetching more.",
None,
)
if neighbor_pages >= state.per_neighbor_page_budget:
return (
f"Per-neighbor excerpt budget for {designator} exhausted "
f"({neighbor_pages}/{state.per_neighbor_page_budget} pages). "
f"You have read enough of {designator}'s datasheet; submit "
f"WARNING with an explicit Unverified: assumption if the spec "
f"still isn't resolved.",
None,
)
if state.page_count >= state.page_budget:
return (
f"Excerpt page budget exhausted ({state.page_count}/"
f"{state.page_budget} pages used). Submit WARNING with an "
f"explicit Unverified: assumption rather than fetching more.",
None,
)
pdf_path = _resolve_neighbor_pdf(state, mpn)
if pdf_path is None:
return (
f"No datasheet PDF available for {designator} ({mpn}). Submit "
f"WARNING with an explicit Unverified: assumption stating what "
f"you needed to verify.",
None,
)
# Stable cache key — md5 the source PDF once, reuse across ICs.
import hashlib
try:
ds_md5 = hashlib.md5(pdf_path.read_bytes()).hexdigest()
except Exception:
log.exception("excerpt: md5 failed for %s", pdf_path)
ds_md5 = pdf_path.name
cache_key = (designator, topic, ds_md5)
cache_val = state.cache.get(cache_key)
pages: list[int]
trimmed_path: str
if (
isinstance(cache_val, tuple)
and len(cache_val) == 2
and Path(cache_val[0]).is_file()
):
trimmed_path, pages = cache_val # type: ignore[assignment]
else:
keyword_re = EXCERPT_TOPICS[topic]
remaining_budget = min(
_EXCERPT_MAX_PAGES_PER_FETCH,
max(1, state.page_budget - state.page_count),
max(1, state.per_neighbor_page_budget - neighbor_pages),
)
trimmed_path, pages = _trim_pdf_by_keywords(
pdf_path, keyword_re, remaining_budget,
)
state.cache[cache_key] = (trimmed_path, pages)
# Update per-review budget counters
state.fetch_count += 1
state.page_count += len(pages)
state.pages_per_neighbor[designator] = neighbor_pages + len(pages)
block = PdfBlock(path=Path(trimmed_path), cacheable=True)
summary = (
f"Returned {len(pages)} pages from {designator} ({mpn}) matching "
f"topic '{topic}': pages {pages}. The PDF excerpt is attached to "
f"this message — read it and cite the printed page number from the "
f"original datasheet in any resulting finding. These pages are from "
f"{designator}'s datasheet (not the component under review), so set "
f"that finding's source_designator to \"{designator}\" — otherwise the "
f"page number would resolve against the wrong datasheet."
)
return summary, block
# ---------------------------------------------------------------------------
# Tool schemas (for Claude API)
# ---------------------------------------------------------------------------
FIND_CONNECTED_COMPONENTS_SCHEMA = {
"name": "find_connected_components",
"description": (
"Find all components connected to the same net as a specific pin. "
"Returns net info and each component with full specs and pin map. "
"Use designator_filter to narrow results (e.g. 'C' for capacitors, 'R' for resistors)."
),
"input_schema": {
"type": "object",
"properties": {
"designator": {
"type": "string",
"description": "Component reference, e.g. 'U1', 'U2'",
},
"pin": {
"type": "string",
"description": "Pin number, e.g. '1', '7'",
},
"designator_filter": {
"type": "string",
"description": "Optional prefix filter: 'C' for caps, 'R' for resistors, 'U' for ICs, etc.",
},
},
"required": ["designator", "pin"],
},
}
GET_NET_FOR_PIN_SCHEMA = {
"name": "get_net_for_pin",
"description": (
"Get the net name, type, and voltage for a specific pin. "
"Lightweight — no component listing. Use for quick voltage checks."
),
"input_schema": {
"type": "object",
"properties": {
"designator": {
"type": "string",
"description": "Component reference, e.g. 'U1'",
},
"pin": {
"type": "string",
"description": "Pin number, e.g. '1'",
},
},
"required": ["designator", "pin"],
},
}
GET_PINTABLE_SCHEMA = {
"name": "get_pintable",
"description": (
"Get the full pin mapping for a component: pin numbers, names, "
"net connections, and whether each pin is connected or unconnected. "
"Use when pin naming is ambiguous or to check for floating pins."
),
"input_schema": {
"type": "object",
"properties": {
"designator": {
"type": "string",
"description": "Component reference, e.g. 'U1'",
},
},
"required": ["designator"],
},
}
SUBMIT_REVIEW_SCHEMA = {
"name": "submit_review",
"description": (
"Submit all findings from your review. Only include issues in findings — "
"do not submit findings for things that are correct. "
"List what you checked and found OK in checked_areas."
),
"input_schema": {
"type": "object",
"properties": {
"findings": {
"type": "array",
"description": "List of issues found. Empty array if no issues.",
"items": {
"type": "object",
"properties": {
"finding": {
"type": "string",
"description": "What you observed in the actual circuit. 1-3 sentences.",
},
"why": {
"type": "string",
"description": "Why this matters — what the datasheet says and what could go wrong. 1-3 sentences.",
},
"status": {
"type": "string",
"enum": ["ERROR", "WARNING", "INFO"],
"description": "ERROR: will cause malfunction. WARNING: may degrade reliability. INFO: worth noting.",
},
"source_page": {
"type": "integer",
"description": "Datasheet page number where the requirement is stated.",
},
"source_quote": {
"type": "string",
"description": (
"The exact verbatim text from the datasheet that "
"states this requirement — copy it "
"character-for-character (max ~200 chars). Omit "
"if the evidence is only in a figure or a "
"rasterized table with no selectable text."
),
},
"source_designator": {
"type": "string",
"description": (
"Designator of the component whose datasheet "
"source_page and source_quote refer to. OMIT "
"this when the page/quote is from the component "
"you are reviewing (its own datasheet — the "
"common case). Set it ONLY when the evidence "
"came from a connected component's datasheet "
"that you fetched with get_datasheet_excerpt "
"(e.g. \"U3\"), so source_page resolves to the "
"correct datasheet."
),
},
"recommendation": {
"type": "string",
"description": "What to change to fix the issue. Only for ERROR/WARNING.",
},
},
"required": ["finding", "why", "status", "source_page"],
},
},
"checked_areas": {
"type": "array",
"description": (
"Areas you reviewed and found correct. Short labels, e.g. "
"'input decoupling', 'output capacitor', 'enable logic', "
"'crystal circuit', 'voltage margins', 'reset circuit'."
),
"items": {"type": "string"},
},
},
"required": ["findings", "checked_areas"],
},
}
GET_DATASHEET_EXCERPT_SCHEMA = {
"name": "get_datasheet_excerpt",
"description": (
"Fetch a focused excerpt of a *connected* IC's datasheet — the pages "
"covering one topic (abs-max, electrical characteristics, 5V-tolerance, "
"etc.). Use this BEFORE flagging any cross-IC interface issue that "
"depends on the counterpart's spec. Restricted to ICs that share a "
"signal net with the IC under review. Subject to a per-review fetch "
"budget; if exhausted, submit WARNING with an explicit Unverified: "
"assumption rather than guessing."
),
"input_schema": {
"type": "object",
"properties": {
"designator": {
"type": "string",
"description": (
"Reference of a connected IC (e.g. 'U3'). Must be a "
"signal neighbor of the IC under review."
),
},
"topic": {
"type": "string",
"enum": sorted(EXCERPT_TOPICS.keys()),
"description": (
"Which datasheet section to pull. Pick the narrowest "
"topic that covers the spec you need — pin_voltage_levels "
"for 5V-tolerance / VIH / VIL, absolute_max for stress "
"ratings, electrical_characteristics for drive "
"strengths, application_circuit for reference designs."
),
},
},
"required": ["designator", "topic"],
},
}
GRAPH_TOOLS = [
FIND_CONNECTED_COMPONENTS_SCHEMA,
GET_NET_FOR_PIN_SCHEMA,
GET_PINTABLE_SCHEMA,
GET_DATASHEET_EXCERPT_SCHEMA,
]
ALL_TOOLS = GRAPH_TOOLS + [SUBMIT_REVIEW_SCHEMA]
# ---------------------------------------------------------------------------
# Dispatcher
# ---------------------------------------------------------------------------
def execute_tool(
graph: DesignGraph,
constraints_map: ConstraintsMap,
tool_name: str,
tool_input: dict,
state: ExcerptState | None = None,
):
"""Execute a graph-query tool call.
Returns ``(text, attachment)`` where ``attachment`` is an optional
PdfBlock the caller should append to the next user message alongside the
tool_result. All tools except ``get_datasheet_excerpt`` return
``(text, None)``.
"""
if tool_name == "find_connected_components":
return (
find_connected_components(
graph, constraints_map,
tool_input["designator"],
tool_input["pin"],
tool_input.get("designator_filter"),
),
None,
)
if tool_name == "get_net_for_pin":
return (
get_net_for_pin(
graph, constraints_map,
tool_input["designator"],
tool_input["pin"],
),
None,
)
if tool_name == "get_pintable":
return (
get_pintable(
graph, constraints_map,
tool_input["designator"],
),
None,
)
if tool_name == "get_datasheet_excerpt":
return get_datasheet_excerpt(
graph, constraints_map,
tool_input.get("designator", ""),
tool_input.get("topic", ""),
state,
)
return (f"Unknown tool: {tool_name}", None)