Rebrand Pinscope to Periscope across product and codebase.

Rename the core package to periscopex, update UI/docs/Docker/deploy defaults to periscope.michelebigi.it, and keep legacy version/storage key aliases so existing projects keep working.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-13 20:02:04 +02:00
co-authored by Cursor
parent 556306bf4d
commit 8d2b85600f
177 changed files with 869 additions and 766 deletions
View File
+321
View File
@@ -0,0 +1,321 @@
"""Parametric PCB antenna templates → segments, SVG, KiCad footprint.
Templates (IFA / meander / stub) use a documented λ/4 electrical length with
εeff≈(εr+1)/2. This is a routing-first drawing aid — not an EM / VSWR result.
"""
from __future__ import annotations
import math
from typing import Literal
from pydantic import BaseModel, Field
_C_MPS = 299_792_458.0
AntennaTemplate = Literal["ifa", "meander", "stub"]
FitStatus = Literal["ok", "scaled", "overflow", "need_f0"]
_NOTE = (
"Parametric template from λ/4 (εeff≈(εr+1)/2) — routing aid only, "
"not an EM / VSWR result. Tune matching on the board."
)
class AntennaSegment(BaseModel):
points: list[tuple[float, float]] # local mm, origin = feed
width_mm: float
class AntennaGeometry(BaseModel):
template: AntennaTemplate
fit: FitStatus
segments: list[AntennaSegment] = Field(default_factory=list)
total_length_mm: float | None = None
length_ideal_mm: float | None = None
scale: float = 1.0
svg: str | None = None
kicad_mod: str | None = None
footprint_name: str | None = None
note: str = _NOTE
detail: str = ""
def quarter_wave_mm(f0_mhz: float, er: float) -> float:
"""Electrical λ/4 in mm using εeff≈(εr+1)/2."""
eeff = (er + 1.0) / 2.0
f_hz = f0_mhz * 1e6
return (_C_MPS / (4.0 * f_hz * math.sqrt(eeff))) * 1e3
def build_geometry(
template: AntennaTemplate,
*,
f0_mhz: float | None,
w_mm: float,
er: float,
zone_bbox_mm: tuple[float, float, float, float] | None = None,
feed_xy: tuple[float, float] | None = None,
) -> AntennaGeometry:
if f0_mhz is None or f0_mhz <= 0:
return AntennaGeometry(
template=template,
fit="need_f0",
detail="Set f0 (MHz) to generate radiator geometry.",
)
if w_mm <= 0:
return AntennaGeometry(
template=template,
fit="overflow",
detail="Feed width w_mm must be > 0.",
)
ideal = quarter_wave_mm(f0_mhz, er)
segs_local, length = _template_segments(template, ideal, w_mm)
fit: FitStatus = "ok"
scale = 1.0
detail = f"{template.upper()} template at {f0_mhz:g} MHz."
avail = _available_span(zone_bbox_mm, feed_xy)
if avail is not None:
need_w, need_h = _bbox_size(segs_local)
free_w, free_h = avail
max_span = max(free_w, free_h)
need_span = max(need_w, need_h)
if need_span > max_span + 1e-6 and max_span > 0:
scale = max_span / need_span
min_scale = 0.45
if scale < min_scale:
return AntennaGeometry(
template=template,
fit="overflow",
length_ideal_mm=round(ideal, 2),
total_length_mm=None,
scale=round(scale, 4),
detail=(
f"Zone too small for {template.upper()} "
f"(need ~{need_span:.1f} mm, have {max_span:.1f} mm)."
),
)
segs_local = _scale_segments(segs_local, scale)
length *= scale
fit = "scaled"
detail = (
f"Scaled to {scale:.2f}× to fit antenna zone "
f"({max_span:.1f} mm free). Retune matching."
)
name = f"Antenna_{template.upper()}_{int(round(f0_mhz))}"
svg = _segments_to_svg(segs_local, w_mm)
mod = _segments_to_kicad_mod(name, segs_local, w_mm, template)
return AntennaGeometry(
template=template,
fit=fit,
segments=segs_local,
total_length_mm=round(length, 2),
length_ideal_mm=round(ideal, 2),
scale=round(scale, 4),
svg=svg,
kicad_mod=mod,
footprint_name=name,
detail=detail,
)
def _template_segments(
template: AntennaTemplate,
length_mm: float,
w_mm: float,
) -> tuple[list[AntennaSegment], float]:
if template == "ifa":
return _ifa(length_mm, w_mm)
if template == "meander":
return _meander(length_mm, w_mm)
return _stub(length_mm, w_mm)
def _ifa(length_mm: float, w_mm: float) -> tuple[list[AntennaSegment], float]:
"""Inverted-F: shorting stub + horizontal arm; feed on the arm at origin.
Local: feed (0,0) on the arm. Shorting at x=-d toward -Y (GND edge).
Arm runs to +X. Proportions: stub ≈ 0.12 L, feed offset ≈ 0.15 L.
"""
L = max(length_mm, 4.0 * w_mm)
stub_h = max(0.12 * L, 2.0 * w_mm)
d = max(0.15 * L, 2.0 * w_mm)
open_x = L - d
segs = [
AntennaSegment(points=[(-d, 0.0), (-d, -stub_h)], width_mm=w_mm),
AntennaSegment(points=[(-d, 0.0), (open_x, 0.0)], width_mm=w_mm),
]
path = stub_h + L
return segs, path
def _meander(length_mm: float, w_mm: float) -> tuple[list[AntennaSegment], float]:
"""Serpentine that consumes ~length_mm inside a compact bbox."""
pitch = max(3.0 * w_mm, 1.2)
run = max(length_mm / 6.0, 4.0 * w_mm)
pts: list[tuple[float, float]] = [(0.0, 0.0)]
x = 0.0
y = 0.0
going_up = True
consumed = 0.0
target = max(length_mm, 4.0 * w_mm)
guard = 0
while consumed < target - 1e-6 and guard < 80:
guard += 1
dy = run if going_up else -run
remain = target - consumed
if remain < abs(dy):
dy = math.copysign(remain, dy)
y2 = y + dy
pts.append((x, y2))
consumed += abs(dy)
y = y2
if consumed >= target - 1e-6:
break
remain = target - consumed
dx = min(pitch, remain)
x2 = x + dx
pts.append((x2, y))
consumed += dx
x = x2
going_up = not going_up
segs = [AntennaSegment(points=pts, width_mm=w_mm)]
return segs, consumed
def _stub(length_mm: float, w_mm: float) -> tuple[list[AntennaSegment], float]:
"""Open L-stub monopole: short vertical then horizontal arm."""
L = max(length_mm, 4.0 * w_mm)
h = max(0.2 * L, 2.0 * w_mm)
arm = max(L - h, 2.0 * w_mm)
segs = [
AntennaSegment(points=[(0.0, 0.0), (0.0, -h)], width_mm=w_mm),
AntennaSegment(points=[(0.0, -h), (arm, -h)], width_mm=w_mm),
]
return segs, h + arm
def _available_span(
zone_bbox: tuple[float, float, float, float] | None,
feed_xy: tuple[float, float] | None,
) -> tuple[float, float] | None:
"""Free width/height from feed into the zone (mm)."""
if zone_bbox is None or feed_xy is None:
return None
xmin, ymin, xmax, ymax = zone_bbox
fx, fy = feed_xy
fx = min(max(fx, xmin), xmax)
fy = min(max(fy, ymin), ymax)
free_w = max(fx - xmin, xmax - fx)
free_h = max(fy - ymin, ymax - fy)
return free_w, free_h
def _bbox_size(segs: list[AntennaSegment]) -> tuple[float, float]:
xs: list[float] = []
ys: list[float] = []
for s in segs:
for x, y in s.points:
xs.append(x)
ys.append(y)
if not xs:
return 0.0, 0.0
return max(xs) - min(xs), max(ys) - min(ys)
def _scale_segments(
segs: list[AntennaSegment], scale: float,
) -> list[AntennaSegment]:
out: list[AntennaSegment] = []
for s in segs:
out.append(
AntennaSegment(
points=[(x * scale, y * scale) for x, y in s.points],
width_mm=s.width_mm,
)
)
return out
def _segments_to_svg(segs: list[AntennaSegment], default_w: float) -> str:
xs: list[float] = []
ys: list[float] = []
for s in segs:
for x, y in s.points:
xs.append(x)
ys.append(y)
if not xs:
return '<svg xmlns="http://www.w3.org/2000/svg" width="120" height="80"/>'
pad = max(default_w * 2, 1.0)
xmin, xmax = min(xs) - pad, max(xs) + pad
ymin, ymax = min(ys) - pad, max(ys) + pad
bw = max(xmax - xmin, 1e-3)
bh = max(ymax - ymin, 1e-3)
paths: list[str] = []
for s in segs:
if len(s.points) < 2:
continue
d_parts = []
for i, (x, y) in enumerate(s.points):
cmd = "M" if i == 0 else "L"
d_parts.append(f"{cmd}{x:.3f},{-y:.3f}")
sw = s.width_mm
paths.append(
f'<path d="{" ".join(d_parts)}" fill="none" stroke="#1a1a1a" '
f'stroke-width="{sw:.3f}" stroke-linecap="round" '
f'stroke-linejoin="round"/>'
)
paths.append(
f'<circle cx="0" cy="0" r="{max(default_w, 0.3):.3f}" fill="#c45c26"/>'
)
vb = f"{xmin:.3f} {-ymax:.3f} {bw:.3f} {bh:.3f}"
body = "\n ".join(paths)
return (
f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="{vb}" '
f'width="280" height="160" style="background:#f7f5f2">'
f"\n {body}\n</svg>"
)
def _segments_to_kicad_mod(
name: str,
segs: list[AntennaSegment],
w_mm: float,
template: AntennaTemplate,
) -> str:
lines = [
f'(footprint "{name}"',
" (version 20240108)",
' (generator "periscope")',
' (layer "F.Cu")',
f' (descr "Periscope {template.upper()} PCB antenna template '
f'— not EM-validated")',
" (attr smd)",
f' (pad "1" smd circle (at 0 0) (size {w_mm * 2:.4f} {w_mm * 2:.4f}) '
f'(layers "F.Cu") (uuid 00000000-0000-4000-8000-000000000001))',
]
if template == "ifa" and segs:
tip = segs[0].points[-1]
lines.append(
f' (pad "2" smd circle (at {tip[0]:.4f} {tip[1]:.4f}) '
f"(size {w_mm * 2:.4f} {w_mm * 2:.4f}) "
f'(layers "F.Cu") (uuid 00000000-0000-4000-8000-000000000002))'
)
uid = 10
for s in segs:
pts = s.points
for i in range(len(pts) - 1):
x1, y1 = pts[i]
x2, y2 = pts[i + 1]
lines.append(
f" (fp_line (start {x1:.4f} {y1:.4f}) (end {x2:.4f} {y2:.4f}) "
f"(stroke (width {s.width_mm:.4f}) (type default)) "
f'(layer "F.Cu") (uuid 00000000-0000-4000-8000-{uid:012d}))'
)
uid += 1
lines.append(")")
return "\n".join(lines) + "\n"
+541
View File
@@ -0,0 +1,541 @@
"""RF antenna verify + design recipe (schema + optional PCB).
Verify: matching topology from IC ANT/RF pin toward ANT footprint / ANT_FEED.
Design: KiCad marker (ANT* footprint or ANT_FEED/RF_ANT net) → microstrip w
for target Z0 from stackup; optional λ/4 length if f0_mhz is given;
parametric IFA / meander / stub geometry (segments + SVG + .kicad_mod).
No EM/VSWR. No CPWG clearance. Geometry is a documented routing template only.
"""
from __future__ import annotations
import math
import re
from typing import Any, Literal
from pydantic import BaseModel
from backend.periscopex.antenna_geometry import (
AntennaGeometry,
AntennaTemplate,
build_geometry,
)
from backend.periscopex.impedance import GeometryError, solve_width
from backend.periscopex.models import (
ComponentType,
DesignGraph,
LayoutGraph,
)
_C_MPS = 299_792_458.0
_ANT_PIN_RE = re.compile(
r"(?:^|[_/\-])(ANT|ANTENNA|RF(?:IO|OUT|IN)?|RF_OUT|RF_IN|LNA|TX|RX)(?:$|[_/\-\d])",
re.IGNORECASE,
)
_FEED_NET_RE = re.compile(
r"^(?:ANT_FEED|ANTENNA_FEED)$",
re.IGNORECASE,
)
_ZONE_NET_RE = re.compile(
r"(?:^|[_/\-])(antenna|ant_zone|rf_antenna)(?:$|[_/\-])",
re.IGNORECASE,
)
Topology = Literal[
"direct", "series_L", "LC", "pi", "T", "unknown", "missing",
]
Status = Literal["ok", "warning", "info"]
DesignStatus = Literal["ready", "need_pcb", "need_stackup", "need_marker"]
class AntennaVerifyRow(BaseModel):
ic_ref: str
pin: str
net: str
topology: Topology
parts: list[str] = []
target_z_ohm: float = 50.0
status: Status = "info"
detail: str = ""
feed_z0: float | None = None
feed_length_mm: float | None = None
marker_ref: str | None = None
class AntennaFeedLine(BaseModel):
kind: str = "microstrip"
target_z_ohm: float = 50.0
w_mm: float | None = None
h_mm: float | None = None
er: float | None = None
t_mm: float | None = None
class AntennaRadiator(BaseModel):
length_mm_suggest: float | None = None
f0_mhz: float | None = None
note: str = (
"λ/4 estimate using εeff≈(εr+1)/2 — routing-first only, not an EM result."
)
class AntennaZoneInfo(BaseModel):
net: str
layer: str
bbox_mm: tuple[float, float, float, float] | None = None # xmin,ymin,xmax,ymax
area_mm2: float | None = None
class AntennaDesignRecipe(BaseModel):
status: DesignStatus
feed_point: dict[str, Any] | None = None
feed_line: AntennaFeedLine | None = None
radiator: AntennaRadiator | None = None
geometry: AntennaGeometry | None = None
zone: AntennaZoneInfo | None = None
keepout_checklist: list[str] = []
detail: str = ""
class AntennaReport(BaseModel):
verify: list[AntennaVerifyRow] = []
design: AntennaDesignRecipe | None = None
marker_help: str = (
"Mark the feed join in KiCad: footprint Ref starting with ANT, "
"or net named ANT_FEED / RF_ANT. Optional zone net 'antenna' for the canvas."
)
def build_antenna_report(
graph: DesignGraph,
layout: LayoutGraph | None = None,
*,
impedance_nets: dict | None = None,
f0_mhz: float | None = None,
target_z_ohm: float = 50.0,
h_mm: float | None = None,
er: float | None = None,
t_mm: float | None = None,
template: AntennaTemplate = "ifa",
) -> AntennaReport:
verify = _verify(graph, layout, impedance_nets, target_z_ohm)
design = build_design_recipe(
graph, layout,
f0_mhz=f0_mhz,
target_z_ohm=target_z_ohm,
h_mm=h_mm,
template=template,
er=er,
t_mm=t_mm,
)
return AntennaReport(verify=verify, design=design)
def build_design_recipe(
graph: DesignGraph,
layout: LayoutGraph | None = None,
*,
f0_mhz: float | None = None,
target_z_ohm: float = 50.0,
h_mm: float | None = None,
er: float | None = None,
t_mm: float | None = None,
template: AntennaTemplate = "ifa",
) -> AntennaDesignRecipe:
marker = _find_marker(graph, layout)
zone = _find_antenna_zone(layout)
checklist = [
"Keep copper / pours out of the antenna keepout unless the antenna datasheet allows it.",
"Short GND return from the matching network to the RF reference.",
"Avoid long stubs and right angles on the 50 Ω feed.",
"Place matching parts close to the RF pin / feed point.",
"IFA pad 2 (shorting tip) must connect to RF ground / pour edge.",
"Place the footprint with feed (pad 1) on the ANT* / ANT_FEED join.",
]
stack = _resolve_stackup(layout, h_mm=h_mm, er=er, t_mm=t_mm)
if marker is None and layout is None:
return AntennaDesignRecipe(
status="need_pcb",
zone=zone,
keepout_checklist=checklist,
detail="Upload a .kicad_pcb (and mark ANT* / ANT_FEED) to compute feed width.",
)
if marker is None:
return AntennaDesignRecipe(
status="need_marker",
zone=zone,
keepout_checklist=checklist,
detail="No ANT* footprint or ANT_FEED/RF_ANT net found.",
)
if stack is None:
return AntennaDesignRecipe(
status="need_stackup",
feed_point=marker,
zone=zone,
keepout_checklist=checklist,
detail="PCB stackup missing εr/h — set stackup in KiCad or pass h/er in the request.",
)
h, er_v, t = stack
try:
w = solve_width("microstrip", target_z_ohm, h, er_v, t, s=None)
except GeometryError as exc:
return AntennaDesignRecipe(
status="need_stackup",
feed_point=marker,
zone=zone,
keepout_checklist=checklist,
detail=str(exc),
)
feed = AntennaFeedLine(
kind="microstrip",
target_z_ohm=target_z_ohm,
w_mm=round(w, 4),
h_mm=h,
er=er_v,
t_mm=t,
)
radiator = None
if f0_mhz is not None and f0_mhz > 0:
eeff = (er_v + 1.0) / 2.0
f_hz = f0_mhz * 1e6
length_m = _C_MPS / (4.0 * f_hz * math.sqrt(eeff))
radiator = AntennaRadiator(
length_mm_suggest=round(length_m * 1e3, 2),
f0_mhz=f0_mhz,
)
feed_xy = None
if marker.get("x") is not None and marker.get("y") is not None:
feed_xy = (float(marker["x"]), float(marker["y"]))
zone_bbox = zone.bbox_mm if zone else None
geometry = build_geometry(
template,
f0_mhz=f0_mhz,
w_mm=float(feed.w_mm or 0),
er=er_v,
zone_bbox_mm=zone_bbox,
feed_xy=feed_xy,
)
detail = "Recipe ready — feed at w_mm; geometry is a parametric template (not EM)."
if geometry.fit == "need_f0":
detail = "Feed w ready — set f0 to generate IFA / meander / stub geometry."
elif geometry.fit == "scaled":
detail = geometry.detail
elif geometry.fit == "overflow":
detail = geometry.detail
return AntennaDesignRecipe(
status="ready",
feed_point=marker,
feed_line=feed,
radiator=radiator,
geometry=geometry,
zone=zone,
keepout_checklist=checklist,
detail=detail,
)
def _verify(
graph: DesignGraph,
layout: LayoutGraph | None,
impedance_nets: dict | None,
target_z: float,
) -> list[AntennaVerifyRow]:
z_by_net = _z0_index(impedance_nets)
rows: list[AntennaVerifyRow] = []
for ref, comp in sorted(graph.components.items()):
if comp.component_type != ComponentType.IC:
continue
for pin_num, net in comp.pins.items():
if not net or not _looks_rf_pin(graph, ref, pin_num, net, comp.component_subtype):
continue
topo, parts, marker, detail, status = _classify_path(graph, ref, net)
z0, length = None, None
if net in z_by_net:
z0 = z_by_net[net].get("z0_avg_ohms")
length = z_by_net[net].get("length_mm")
elif marker and marker.get("net") and marker["net"] in z_by_net:
info = z_by_net[marker["net"]]
z0 = info.get("z0_avg_ohms")
length = info.get("length_mm")
rows.append(AntennaVerifyRow(
ic_ref=ref,
pin=str(pin_num),
net=net,
topology=topo,
parts=parts,
target_z_ohm=target_z,
status=status,
detail=detail,
feed_z0=z0,
feed_length_mm=length,
marker_ref=marker.get("ref") if marker else None,
))
return rows
def _looks_rf_pin(
graph: DesignGraph,
ref: str,
pin_num: str,
net: str,
subtype: str | None,
) -> bool:
if _FEED_NET_RE.match(net or ""):
return True
if _ANT_PIN_RE.search(net or ""):
return True
# Pin name from netlist is often just the net; subtype helps for modules.
sub = (subtype or "").lower()
if sub.startswith("ic.rf") and _ANT_PIN_RE.search(net or ""):
return True
if sub.startswith("ic.rf"):
# Common module pad names appear as nets
u = (net or "").upper()
if any(k in u for k in ("ANT", "RF", "LNA", "WIFI")):
return True
return bool(_ANT_PIN_RE.search(str(pin_num)))
def _classify_path(
graph: DesignGraph,
ic_ref: str,
start_net: str,
) -> tuple[Topology, list[str], dict | None, str, Status]:
"""BFS a few hops of passives toward ANT marker / connector."""
marker = _marker_on_net(graph, start_net)
if marker:
return "direct", [], marker, "Feed net is the antenna marker.", "ok"
parts: list[str] = []
kinds: list[str] = []
visited_nets = {start_net}
frontier = [start_net]
found_marker: dict | None = None
found_connector = False
for _ in range(4):
next_frontier: list[str] = []
for net in frontier:
for cref in _passives_on_net(graph, net):
if cref in parts:
continue
other = graph.components[cref]
ctype = other.component_type
if ctype == ComponentType.CONNECTOR:
found_connector = True
parts.append(cref)
continue
if cref.upper().startswith("ANT"):
found_marker = {"ref": cref, "net": net, "kind": "footprint"}
parts.append(cref)
continue
if ctype not in (
ComponentType.RESISTOR,
ComponentType.CAPACITOR,
ComponentType.INDUCTOR,
):
continue
parts.append(cref)
if ctype == ComponentType.INDUCTOR:
kinds.append("L")
elif ctype == ComponentType.CAPACITOR:
kinds.append("C")
elif ctype == ComponentType.RESISTOR:
kinds.append("R")
for n2 in other.pins.values():
if not n2 or n2 in visited_nets:
continue
visited_nets.add(n2)
next_frontier.append(n2)
m = _marker_on_net(graph, n2)
if m:
found_marker = m
frontier = next_frontier
if found_marker or (found_connector and not frontier):
break
if found_marker or found_connector:
topo = _topo_from_kinds(kinds)
who = found_marker.get("ref") if found_marker else "connector"
return topo, parts, found_marker, f"Path to {who}: {topo}.", "ok"
if parts:
return (
"unknown",
parts,
None,
"Passives on RF net but no ANT* / ANT_FEED / connector reached.",
"warning",
)
return (
"missing",
[],
None,
"No matching network found between RF pin and antenna marker.",
"warning",
)
def _topo_from_kinds(kinds: list[str]) -> Topology:
s = "".join(kinds)
if not s:
return "direct"
if s in ("L",):
return "series_L"
if s in ("LC", "CL"):
return "LC"
if s.count("C") >= 2 and "L" in s:
return "pi"
if s.count("L") >= 2 and "C" in s:
return "T"
if "L" in s and "C" in s:
return "LC"
if "L" in s:
return "series_L"
return "unknown"
def _passives_on_net(graph: DesignGraph, net: str) -> list[str]:
out: list[str] = []
net_obj = graph.nets.get(net)
if not net_obj:
return out
for pc in net_obj.pins:
cref = pc.component_ref
comp = graph.components.get(cref)
if not comp or comp.component_type == ComponentType.IC:
continue
out.append(cref)
return sorted(set(out))
def _marker_on_net(graph: DesignGraph, net: str) -> dict | None:
if _FEED_NET_RE.match(net or ""):
return {"ref": None, "net": net, "kind": "net"}
# Dedicated join alias only when an ANT* part sits on the net.
for cref in _passives_on_net(graph, net):
if cref.upper().startswith("ANT"):
return {"ref": cref, "net": net, "kind": "footprint"}
comp = graph.components[cref]
if comp.component_type == ComponentType.CONNECTOR and (
cref.upper().startswith("ANT")
or _ANT_PIN_RE.search((comp.value or "") + cref)
):
return {"ref": cref, "net": net, "kind": "connector"}
return None
def _find_marker(graph: DesignGraph, layout: LayoutGraph | None) -> dict | None:
# Prefer layout footprints ANT*
if layout:
for ref, fp in sorted(layout.footprints.items()):
if ref.upper().startswith("ANT"):
net = next((p.net for p in fp.pads if p.net), None)
return {
"ref": ref,
"net": net,
"kind": "footprint",
"x": fp.x,
"y": fp.y,
"layer": fp.layer,
}
for net_name in layout.nets:
if _FEED_NET_RE.match(net_name) or net_name.upper() == "RF_ANT":
# RF_ANT as board join only if ANT* footprint uses it
if net_name.upper() == "RF_ANT":
if not any(
r.upper().startswith("ANT")
for r, fp in layout.footprints.items()
if any(p.net == net_name for p in fp.pads)
):
continue
return {"ref": None, "net": net_name, "kind": "net"}
for ref, comp in sorted(graph.components.items()):
if ref.upper().startswith("ANT"):
nets = [n for n in comp.pins.values() if n]
return {
"ref": ref,
"net": nets[0] if nets else None,
"kind": "footprint",
}
for net in comp.pins.values():
if net and _FEED_NET_RE.match(net):
return {"ref": ref if comp.component_type != ComponentType.IC else None,
"net": net, "kind": "net"}
return None
def _find_antenna_zone(layout: LayoutGraph | None) -> AntennaZoneInfo | None:
if not layout:
return None
for z in layout.zones:
if not _ZONE_NET_RE.search(z.net or ""):
continue
bbox, area = _outline_metrics(z.outlines)
return AntennaZoneInfo(
net=z.net,
layer=z.layer,
bbox_mm=bbox,
area_mm2=area,
)
return None
def _outline_metrics(
outlines: list[list[tuple[float, float]]],
) -> tuple[tuple[float, float, float, float] | None, float | None]:
pts: list[tuple[float, float]] = []
for ring in outlines:
pts.extend(ring)
if len(pts) < 3:
return None, None
xs = [p[0] for p in pts]
ys = [p[1] for p in pts]
bbox = (min(xs), min(ys), max(xs), max(ys))
# Shoelace on first ring only
ring = outlines[0]
area = 0.0
for i in range(len(ring)):
x1, y1 = ring[i]
x2, y2 = ring[(i + 1) % len(ring)]
area += x1 * y2 - x2 * y1
return bbox, abs(area) / 2.0
def _resolve_stackup(
layout: LayoutGraph | None,
*,
h_mm: float | None,
er: float | None,
t_mm: float | None,
) -> tuple[float, float, float] | None:
if h_mm and er and h_mm > 0 and er > 0:
return float(h_mm), float(er), float(t_mm or 0.035)
if not layout or not layout.stackup or not layout.stackup.dielectrics:
return None
d = layout.stackup.dielectrics[0]
if d.height_mm <= 0 or d.er <= 0:
return None
t = layout.stackup.copper_thickness_mm
return float(d.height_mm), float(d.er), float(t if t and t > 0 else 0.035)
def _z0_index(impedance_nets: dict | None) -> dict[str, dict]:
if not impedance_nets:
return {}
rows = impedance_nets.get("nets") or []
out: dict[str, dict] = {}
for row in rows:
name = row.get("net_name") or row.get("net")
if name:
out[str(name)] = row
return out
+73
View File
@@ -0,0 +1,73 @@
"""BOM vs schematic property matching.
Compares per-reference MPN/value from the schematic property table against
the uploaded BOM. Silent when the schematic map is empty (PADS/EDIF) so
we never invent orphans from a format that has no schematic properties.
"""
from __future__ import annotations
from backend.periscopex.models import Finding
def _norm_mpn(value: object) -> str:
return " ".join(str(value or "").split()).upper()
def check_bom_schematic_match(
schematic: dict[str, dict],
bom: dict[str, dict],
) -> list[Finding]:
if not schematic:
return []
findings: list[Finding] = []
refs = sorted(set(schematic) | set(bom))
for ref in refs:
if ref.startswith("#"):
continue
sch = schematic.get(ref) or {}
bom_row = bom.get(ref) or {}
if ref not in schematic:
findings.append(Finding(
designator=ref,
mpn=str(bom_row.get("mpn") or ""),
aspect="bom_match",
source="bom_match",
status="WARNING",
finding=(
f"BOM lists {ref} but the schematic has no such reference."
),
why=(
"An extra BOM line that is not in the netlist will not be "
"validated against a datasheet and may indicate a stale BOM."
),
recommendation=f"Remove {ref} from the BOM or add it to the schematic.",
rule_id="PE-BOM-002",
pins=[],
))
continue
sch_mpn = _norm_mpn(sch.get("mpn"))
bom_mpn = _norm_mpn(bom_row.get("mpn"))
if sch_mpn and bom_mpn and sch_mpn != bom_mpn:
findings.append(Finding(
designator=ref,
mpn=str(sch.get("mpn") or ""),
aspect="bom_match",
source="bom_match",
status="ERROR",
finding=(
f"{ref} schematic MPN '{sch.get('mpn')}' does not match "
f"BOM MPN '{bom_row.get('mpn')}'."
),
why=(
"Datasheet review and library lookup follow one MPN. "
"A mismatch means the wrong die or a stale BOM row."
),
recommendation=(
f"Make {ref}'s BOM and schematic MPN identical, then re-run."
),
rule_id="PE-BOM-001",
pins=[ref],
))
return findings
+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.periscopex.models import ComponentType, DesignGraph
from backend.periscopex.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", "impedance_ohm")
}
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
+90
View File
@@ -0,0 +1,90 @@
"""periscope-cad-bridge JSON (E2) for the KiCad action plugin."""
from __future__ import annotations
import json
from pathlib import Path
from backend.periscopex.models import CadIndexEntry, DesignGraph, Finding, ValidationReport
CAD_BRIDGE_VERSION = 1
_PCB_RULE_PREFIXES = ("PE-PLC", "PE-SI", "PE-LAY", "PE-3W", "PE-CLR")
def annotate_findings_cad(
findings: list[Finding],
cad_index: dict[str, CadIndexEntry] | None,
) -> None:
"""Fill cad_sheet/cad_uuid from the graph index when the finding omitted them."""
if not cad_index:
return
for f in findings:
entry = cad_index.get(f.designator)
if not entry:
continue
if not f.cad_uuid and entry.uuid:
f.cad_uuid = entry.uuid
if not f.cad_sheet and entry.sheet:
f.cad_sheet = entry.sheet
def _pin_numbers(designator: str, pins: list[str]) -> list[str]:
out: list[str] = []
prefix = designator + "."
for raw in pins:
s = str(raw).strip()
if not s:
continue
if s.upper().startswith(prefix.upper()):
s = s[len(prefix):]
out.append(s)
return out
def _target_kind(rule_id: str | None) -> str:
rid = rule_id or ""
if any(rid.startswith(p) for p in _PCB_RULE_PREFIXES):
return "pcb"
return "sch"
def build_cad_bridge(
report: ValidationReport,
project_id: str,
*,
url_base: str = "",
) -> dict:
"""E2 `periscope-cad-bridge` payload. Missing uuid/sheet stay empty strings."""
findings: list[dict] = []
for f in report.findings:
fid = f.finding_id or ""
url = ""
if url_base and fid:
sep = "&" if "?" in url_base else "?"
url = f"{url_base}{sep}finding={fid}"
findings.append({
"rule_id": f.rule_id or f.source or "review",
"ref": f.designator,
"pins": _pin_numbers(f.designator, f.pins or []),
"sheet": f.cad_sheet or "",
"uuid": f.cad_uuid or "",
"severity": (f.status or "WARNING").lower(),
"message": f.finding,
"url": url,
"finding_id": fid,
"net": f.net or "",
"target": _target_kind(f.rule_id),
})
return {
"version": CAD_BRIDGE_VERSION,
"project_id": project_id,
"findings": findings,
}
def write_cad_bridge(path: str | Path, payload: dict) -> None:
Path(path).write_text(json.dumps(payload, indent=2) + "\n")
def cad_index_from_graph(graph: DesignGraph) -> dict[str, CadIndexEntry]:
return dict(graph.cad_index or {})
+163
View File
@@ -0,0 +1,163 @@
"""Crystal load capacitance vs load caps — numbers only when present.
CL_eff ≈ (C1·C2)/(C1+C2) + Cstray. Cstray used only if specs list it;
never invent a stray default. Without CL in specs → skip.
"""
from __future__ import annotations
from backend.periscopex.functional_groups import (
_cap_farads,
_is_ground_net,
load_capacitance_farads,
)
from backend.periscopex.models import (
Component,
ComponentType,
DesignGraph,
Finding,
SimpleComponentSpecs,
)
_STRAY_KEYS = ("stray_capacitance_f", "board_stray_f", "cstray_f")
def check_crystal_cl(graph: DesignGraph) -> list[Finding]:
findings: list[Finding] = []
for ref, comp in sorted(graph.components.items()):
if comp.component_type != ComponentType.CRYSTAL:
continue
cl = load_capacitance_farads(comp)
if cl is None:
continue
load_caps = _load_caps_for_crystal(graph, comp)
if len(load_caps) < 2:
findings.append(Finding(
designator=ref,
mpn=comp.mpn or "",
aspect="clock",
source="crystal_cl_check",
status="WARNING",
finding=(
f"{ref} specifies CL={_fmt_f(cl)} but fewer than two load "
f"capacitors were found on its non-ground nets "
f"({[c.reference for c in load_caps] or 'none'})."
),
why="Crystal load capacitance needs a matched C1/C2 pair.",
recommendation="Add or value the two load capacitors on XIN/XOUT.",
reference="netlist topology",
rule_id="PE-XTAL-001",
pins=[ref],
))
continue
# Use the two caps with known farads closest to equal (typical C1≈C2).
valued = [(c, _cap_farads(c)) for c in load_caps]
known = [(c, f) for c, f in valued if f is not None]
if len(known) < 2:
continue
known.sort(key=lambda x: x[1])
# Prefer a pair with similar values: take the two largest known if many.
c1, f1 = known[-2]
c2, f2 = known[-1]
series = (f1 * f2) / (f1 + f2) if (f1 + f2) > 0 else None
if series is None:
continue
stray = _stray_farads(comp)
c_eff = series + (stray or 0.0)
if stray is None:
# Without stray: only flag when series alone already exceeds CL.
if series > cl * 1.25:
findings.append(Finding(
designator=ref,
mpn=comp.mpn or "",
aspect="clock",
source="crystal_cl_check",
status="WARNING",
finding=(
f"{ref} CL={_fmt_f(cl)}; C1={c1.reference} {_fmt_f(f1)} and "
f"C2={c2.reference} {_fmt_f(f2)} give series≈{_fmt_f(series)} "
f"(already above CL; board stray not in specs)."
),
why="Series combination of load caps exceeds specified CL without needing stray.",
recommendation="Reduce load caps or confirm the datasheet CL value.",
reference="netlist topology",
rule_id="PE-XTAL-002",
pins=[ref, c1.reference, c2.reference],
))
elif series < cl * 0.5:
findings.append(Finding(
designator=ref,
mpn=comp.mpn or "",
aspect="clock",
source="crystal_cl_check",
status="INFO",
finding=(
f"{ref} CL={_fmt_f(cl)}; series of {c1.reference}/{c2.reference} "
f"{_fmt_f(series)} (stray unknown — verify against datasheet)."
),
why="Without stray capacitance in specs, effective CL cannot be fully checked.",
recommendation="Confirm Cstray or populate load_capacitance / stray in crystal specs.",
reference="netlist topology",
rule_id="PE-XTAL-003",
pins=[ref, c1.reference, c2.reference],
))
continue
if c_eff > cl * 1.25 or c_eff < cl * 0.75:
findings.append(Finding(
designator=ref,
mpn=comp.mpn or "",
aspect="clock",
source="crystal_cl_check",
status="WARNING",
finding=(
f"{ref} CL={_fmt_f(cl)}; C_eff≈{_fmt_f(c_eff)} "
f"(series {_fmt_f(series)} + stray {_fmt_f(stray)}) "
f"from {c1.reference}/{c2.reference}."
),
why="Effective load capacitance should stay near the crystal's specified CL.",
recommendation="Adjust C1/C2 so C_eff ≈ CL.",
reference="netlist topology",
rule_id="PE-XTAL-002",
pins=[ref, c1.reference, c2.reference],
))
return findings
def _load_caps_for_crystal(graph: DesignGraph, crystal: Component) -> list[Component]:
caps: dict[str, Component] = {}
for net in crystal.pins.values():
if not net or _is_ground_net(graph, net):
continue
for cref in graph.capacitors_on_net(net):
cap = graph.components.get(cref)
if cap:
caps[cref] = cap
return list(caps.values())
def _stray_farads(comp: Component) -> float | None:
specs = comp.specs
if not isinstance(specs, SimpleComponentSpecs):
return None
for key in _STRAY_KEYS:
raw = specs.values.get(key)
if raw is None:
continue
try:
v = float(raw)
except (TypeError, ValueError):
continue
if v >= 0:
return v
return None
def _fmt_f(farads: float) -> str:
if farads >= 1e-6:
return f"{farads * 1e6:.3g}µF"
if farads >= 1e-9:
return f"{farads * 1e9:.3g}nF"
return f"{farads * 1e12:.3g}pF"
+186
View File
@@ -0,0 +1,186 @@
"""Build a capacitor voltage derating table from the design graph. No AI — pure computation."""
from __future__ import annotations
import re
from backend.periscopex.models import ComponentType, DesignGraph, NetType
from backend.periscopex.resolve_passives import _format_value
from backend.periscopex.utils import natural_sort_key
# Dielectric strings that indicate ceramic capacitors
_CERAMIC_DIELECTRICS = {"X7R", "X5R", "C0G", "NP0", "Y5V", "X7S", "X6S", "X8R", "C0G (NP0)"}
# Remaining C/C0 vs V/Vrated. Empirical stima, not a vendor lot curve.
_BIAS_CURVES: dict[str, list[tuple[float, float]]] = {
"c0g": [(0.0, 1.0), (1.2, 1.0)],
"x7r": [(0.0, 1.0), (0.25, 0.90), (0.50, 0.70), (0.75, 0.45), (1.0, 0.30), (1.2, 0.22)],
"x5r": [(0.0, 1.0), (0.25, 0.82), (0.50, 0.55), (0.75, 0.32), (1.0, 0.18), (1.2, 0.12)],
"y5v": [(0.0, 1.0), (0.25, 0.50), (0.50, 0.20), (0.80, 0.12), (1.0, 0.10)],
}
def _lerp(curve: list[tuple[float, float]], x: float) -> float:
if x <= curve[0][0]:
return curve[0][1]
for (x0, y0), (x1, y1) in zip(curve, curve[1:]):
if x <= x1:
if x1 == x0:
return y1
t = (x - x0) / (x1 - x0)
return y0 + t * (y1 - y0)
return curve[-1][1]
def _bias_family(dielectric: str | None) -> str | None:
if not dielectric:
return None
u = dielectric.upper()
if "C0G" in u or "NP0" in u or "NPO" in u:
return "c0g"
if "Y5V" in u:
return "y5v"
if "X5R" in u or "X6S" in u:
return "x5r"
if "X7R" in u or "X7S" in u or "X8R" in u:
return "x7r"
return None
def dc_bias_remaining(
dielectric: str | None,
v_op: float | None,
rated_v: float | None,
) -> float | None:
"""Fraction of nominal C remaining under DC bias, or None if not modelled.
Labelled a *stima*: class-2 MLCC curves vary by lot, thickness and vendor.
"""
family = _bias_family(dielectric)
if family is None or v_op is None or rated_v is None or rated_v <= 0:
return None
return _lerp(_BIAS_CURVES[family], max(0.0, v_op) / rated_v)
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
c_nom: float | 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)
c_nom = getattr(comp.specs, "value_farads", 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]
factor = dc_bias_remaining(dielectric, op_voltage, rated_v)
c_eff = (c_nom * factor) if (c_nom is not None and factor is not None) else None
c_eff_fmt = _format_value(c_eff, "F") if c_eff is not None else None
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),
"dielectric": dielectric,
"c_nominal_f": c_nom,
"dc_bias_factor": factor,
"c_eff_f": c_eff,
"c_eff_formatted": c_eff_fmt,
"dc_bias_model": "stima" if factor is not None else None,
})
rows.sort(key=lambda r: natural_sort_key(r["designator"]))
return rows
+112
View File
@@ -0,0 +1,112 @@
"""Enable pins on the fitted variant: no pull and no driver is ERROR.
Runs only when the BOM actually marks DNP/fitted. Enable tied to a rail
is a driver. DNP resistors are removed from the variant graph.
"""
from __future__ import annotations
import re
from backend.periscopex.models import (
ComponentConstraints,
ComponentType,
DesignGraph,
Finding,
)
from backend.periscopex.passive_rail_check import (
_is_ground_net,
_is_power_net,
_pin_name_tokens,
)
from backend.periscopex.validate import _match_constraints
_EN_RE = re.compile(
r"(?:^|[_/])(EN|ENA|ENABLE|n?SHDN|nEN|EN_N|CHIP_EN)(?:$|[_/\d])",
re.I,
)
def _dnp_map(graph: DesignGraph) -> dict[str, bool] | None:
"""Return {ref: is_dnp} if any BOM row carries DNP/fitted, else None."""
fields = graph.bom_fields or {}
if not fields:
return None
if not any("dnp" in (v or {}) or "fitted" in (v or {}) for v in fields.values()):
return None
out: dict[str, bool] = {}
for ref in graph.components:
row = fields.get(ref) or {}
if "dnp" in row:
out[ref] = bool(row.get("dnp"))
elif "fitted" in row:
out[ref] = not bool(row.get("fitted"))
else:
out[ref] = False
return out
def _is_fitted(dnp: dict[str, bool], ref: str) -> bool:
return not dnp.get(ref, False)
def check_dnp_enables(
graph: DesignGraph,
constraints_map: dict[str, ComponentConstraints] | None = None,
) -> list[Finding]:
dnp = _dnp_map(graph)
if dnp is None:
return []
cmap = constraints_map or {}
findings: list[Finding] = []
for ref, comp in sorted(graph.components.items()):
if not _is_fitted(dnp, ref):
continue
if comp.component_type != ComponentType.IC:
continue
cons = _match_constraints(comp.mpn or comp.value, cmap)
for pin_num, net in sorted(comp.pins.items(), key=lambda x: str(x[0])):
tokens = _pin_name_tokens(cons, pin_num)
names = tokens or [net or "", pin_num]
if not any(_EN_RE.search(t) for t in names):
continue
if _is_power_net(graph, net) or _is_ground_net(graph, net):
continue
has_pull = False
has_driver = False
for r in graph.components_on_net(net):
if r == ref or not _is_fitted(dnp, r):
continue
other = graph.components.get(r)
if not other:
continue
if other.component_type == ComponentType.IC:
has_driver = True
continue
if other.component_type != ComponentType.RESISTOR:
continue
others = {n for n in other.pins.values() if n != net}
if any(_is_power_net(graph, n) or _is_ground_net(graph, n) for n in others):
has_pull = True
if has_pull or has_driver:
continue
variant = (graph.bom_fields.get(ref) or {}).get("variant")
findings.append(Finding(
designator=ref,
mpn=comp.mpn or "",
aspect="dnp",
source="dnp_check",
status="ERROR",
finding=(
f"{ref} enable '{net}' has no fitted pull or driver "
f"(DNP parts ignored)."
),
why="On the fitted variant the enable net is floating.",
recommendation="Fit a pull, tie EN to a rail, or drive it from a PG/GPIO.",
reference="BOM DNP/fitted",
net=net,
pins=[f"{ref}.{pin_num}"],
rule_id="PE-DNP-001",
variant=str(variant) if variant else None,
))
return findings
+90
View File
@@ -0,0 +1,90 @@
"""Errata workarounds from a known-URL catalog. No HTML scrape."""
from __future__ import annotations
import logging
import re
from backend.periscopex.models import ComponentConstraints, DesignGraph, Finding
from backend.periscopex.passive_rail_check import (
_pin_name_tokens,
_resistor_to_power,
)
from backend.periscopex.validate import _match_constraints
log = logging.getLogger(__name__)
# Exact MPN → URL + structured workarounds. Empty by default so eval is quiet.
DEFAULT_ERRATA_CATALOG: dict[str, dict] = {}
def check_errata(
graph: DesignGraph,
constraints_map: dict[str, ComponentConstraints] | None,
catalog: dict[str, dict] | None = None,
) -> list[Finding]:
cmap = constraints_map or {}
cat = DEFAULT_ERRATA_CATALOG if catalog is None else catalog
findings: list[Finding] = []
for ref, comp in sorted(graph.components.items()):
mpn = (comp.mpn or "").strip()
if not mpn:
continue
entry = cat.get(mpn)
if entry is None:
continue
url = (entry.get("url") or "").strip()
if not url:
log.info("errata skip %s: catalog row has no url", mpn)
continue
cons = _match_constraints(mpn, cmap)
for wa in entry.get("workarounds") or []:
kind = (wa.get("kind") or "").lower()
pin_name = (wa.get("pin_name") or "").strip()
if kind != "pullup" or not pin_name:
continue
net = _net_for_pin_name(graph, ref, cons, pin_name)
if not net:
continue
if _resistor_to_power(graph, net):
continue
findings.append(Finding(
designator=ref,
mpn=mpn,
aspect="errata",
source="errata_check",
status="WARNING",
finding=(
f"{ref} {pin_name} is missing the errata pull-up on '{net}'."
),
why=wa.get("note") or "Vendor errata workaround is not on the schematic.",
recommendation="Add the pull-up described in the errata, or confirm the die revision.",
reference=url,
net=net,
pins=[f"{ref}.{pin_name}"],
rule_id="PE-ERRATA-001",
))
return findings
def _net_for_pin_name(
graph: DesignGraph,
ref: str,
cons: ComponentConstraints | None,
pin_name: str,
) -> str | None:
comp = graph.components.get(ref)
if not comp:
return None
want = pin_name.upper()
for pin_num, net in comp.pins.items():
if (net or "").upper() == want:
return net
tokens = _pin_name_tokens(cons, pin_num)
if any(t.upper() == want or _token_match(t, pin_name) for t in tokens):
return net
return None
def _token_match(token: str, pin_name: str) -> bool:
return bool(re.search(rf"(?:^|[_/]){re.escape(pin_name)}(?:$|[_/\d])", token, re.I))
+166
View File
@@ -0,0 +1,166 @@
"""Score a validation report against a golden key set.
Used by the simple_project eval harness: finding counts, % Unverified,
citation hit-rate among LLM quotes, precision/recall vs golden keys.
Deterministic checks without a quote are excluded from the citation
denominator so pin-mux/BOM noise cannot inflate the rate.
"""
from __future__ import annotations
from pathlib import Path
from pydantic import BaseModel
from backend.periscopex.models import DesignGraph, Finding, ValidationReport
from backend.periscopex.pin_mux_check import check_pin_mux_feasibility
from backend.periscopex.led_current_check import check_led_current
from backend.periscopex.passive_rail_check import (
check_i2c_pullups,
check_reset_pullups,
check_supply_decoupling,
)
from backend.periscopex.bom_match_check import check_bom_schematic_match
from backend.periscopex.hf_coverage_check import check_hf_decoupling_coverage
from backend.periscopex.filter_check import check_filters
from backend.periscopex.thermal_check import check_thermal
from backend.periscopex.power_margin_check import check_power_margin
from backend.periscopex.sequencing_check import check_power_sequencing
from backend.periscopex.dnp_check import check_dnp_enables
from backend.periscopex.lifecycle import check_lifecycle
from backend.periscopex.errata_check import check_errata
from backend.periscopex.internal_features_check import check_internal_features
from backend.periscopex.placement_check import check_placement
from backend.periscopex.si_check import check_si
class EvalScores(BaseModel):
finding_count: int
by_status: dict[str, int]
unverified_pct: float
citation_hit_rate: float | None
precision: float
recall: float
extra_keys: list[str]
missing_keys: list[str]
graph_ok: bool = True
graph_errors: list[str] = []
def finding_key(f: Finding) -> str:
if f.rule_id:
return f"{f.rule_id}|{f.designator}|{f.net or ''}"
return f"{f.source or 'review'}|{f.designator}|{f.net or f.finding}"
def _is_review(f: Finding) -> bool:
return not f.source or f.source == "review"
def citation_hit_rate(findings: list[Finding]) -> float | None:
quoted = [
f for f in findings
if _is_review(f) and (f.source_quote or "").strip()
]
if not quoted:
return None
hits = sum(1 for f in quoted if not (f.why or "").startswith("Unverified:"))
return hits / len(quoted)
def unverified_pct(findings: list[Finding]) -> float:
if not findings:
return 0.0
n = sum(1 for f in findings if (f.why or "").startswith("Unverified:"))
return 100.0 * n / len(findings)
def score_keys(produced: set[str], golden: set[str]) -> tuple[float, float, list[str], list[str]]:
extra = sorted(produced - golden)
missing = sorted(golden - produced)
precision = 1.0 if not produced else len(produced & golden) / len(produced)
recall = 1.0 if not golden else len(produced & golden) / len(golden)
return precision, recall, extra, missing
def run_deterministic_on_graph(graph: DesignGraph) -> list[Finding]:
cmap: dict = {}
out: list[Finding] = []
out.extend(check_pin_mux_feasibility(graph, cmap))
out.extend(check_led_current(graph))
out.extend(check_supply_decoupling(graph, cmap))
out.extend(check_i2c_pullups(graph, cmap))
out.extend(check_reset_pullups(graph, cmap))
out.extend(check_bom_schematic_match(graph.schematic_fields, graph.bom_fields))
out.extend(check_hf_decoupling_coverage(graph, cmap))
out.extend(check_filters(graph, cmap))
out.extend(check_thermal(graph, cmap))
out.extend(check_power_margin(graph, cmap))
out.extend(check_power_sequencing(graph, cmap))
out.extend(check_dnp_enables(graph, cmap))
out.extend(check_lifecycle(graph, {}))
out.extend(check_errata(graph, cmap))
out.extend(check_internal_features(graph, cmap))
out.extend(check_placement(graph, cmap, None))
out.extend(check_si(graph, cmap, None))
return out
def score_report(
findings: list[Finding],
golden_keys: set[str],
*,
graph: DesignGraph | None = None,
golden_meta: dict | None = None,
) -> EvalScores:
keys = {finding_key(f) for f in findings}
precision, recall, extra, missing = score_keys(keys, golden_keys)
by_status: dict[str, int] = {"ERROR": 0, "WARNING": 0, "INFO": 0}
for f in findings:
by_status[f.status] = by_status.get(f.status, 0) + 1
graph_errors: list[str] = []
if graph is not None and golden_meta:
for ref in golden_meta.get("required_refs") or []:
if ref not in graph.components:
graph_errors.append(f"missing ref {ref}")
min_c = golden_meta.get("min_components")
if min_c and len(graph.components) < int(min_c):
graph_errors.append(
f"components {len(graph.components)} < {min_c}"
)
min_n = golden_meta.get("min_nets")
if min_n and len(graph.nets) < int(min_n):
graph_errors.append(f"nets {len(graph.nets)} < {min_n}")
return EvalScores(
finding_count=len(findings),
by_status=by_status,
unverified_pct=unverified_pct(findings),
citation_hit_rate=citation_hit_rate(findings),
precision=precision,
recall=recall,
extra_keys=extra,
missing_keys=missing,
graph_ok=not graph_errors,
graph_errors=graph_errors,
)
def eval_simple_project(
root: str | Path,
report: ValidationReport | None = None,
) -> EvalScores:
root = Path(root)
graph = DesignGraph.model_validate_json(
(root / "design_graph.json").read_text()
)
golden = {}
gpath = root / "eval_golden.json"
if gpath.is_file():
import json
golden = json.loads(gpath.read_text())
if report is not None:
findings = list(report.findings)
else:
findings = run_deterministic_on_graph(graph)
keys = set(golden.get("deterministic_keys") or [])
return score_report(findings, keys, graph=graph, golden_meta=golden)
+384
View File
@@ -0,0 +1,384 @@
"""Signal-filter topology: RC, LC, ferrite+C, π (C-L-C), T (L-C-L).
fc is reported only when R/L/C values are known. Sample-rate comparison
and ferrite DCR limits fire only when the neighboring IC specs list them.
Power-rail decoupling is not a signal filter.
"""
from __future__ import annotations
import math
import re
from backend.periscopex.models import (
Component,
ComponentConstraints,
ComponentType,
DesignGraph,
Finding,
InductorSpecs,
)
from backend.periscopex.passive_rail_check import (
_cap_farads,
_is_ground_net,
_is_power_net,
_pin_name_tokens,
_resistor_ohms,
)
from backend.periscopex.validate import _match_constraints
_ADC_RATE_KEYS = ("adc_sample_rate", "adc_sample_rate_hz", "data_rate", "data_rate_hz")
_DCR_MAX_KEYS = ("max_ferrite_dcr_ohms", "ferrite_dcr_max_ohms", "max_bead_dcr_ohms")
_ANALOG_RE = re.compile(
r"(?:^|[_/])(ADC|AIN|VDDA|AVDD|VREF)(?:$|[_/\d])",
re.IGNORECASE,
)
def _inductor_henries(comp: Component) -> float | None:
specs = comp.specs
if isinstance(specs, InductorSpecs) and specs.value_henries:
return float(specs.value_henries)
return None
def _dcr_ohms(comp: Component) -> float | None:
specs = comp.specs
if isinstance(specs, InductorSpecs) and specs.dcr_ohms is not None:
return float(specs.dcr_ohms)
return None
def _is_ferrite(comp: Component) -> bool:
sub = (comp.component_subtype or "").lower()
if "ferrite" in sub:
return True
specs = comp.specs
if isinstance(specs, InductorSpecs) and specs.component_subtype:
return "ferrite" in specs.component_subtype
return comp.reference.upper().startswith("FB")
def _two_nets(comp: Component) -> tuple[str, str] | None:
nets = list(dict.fromkeys(comp.pins.values()))
if len(nets) != 2:
return None
return nets[0], nets[1]
def _gnd_caps(graph: DesignGraph, net: str) -> list[tuple[str, float | None]]:
out: list[tuple[str, float | None]] = []
for ref in graph.capacitors_on_net(net):
cap = graph.components[ref]
others = {n for n in cap.pins.values() if n != net}
if any(_is_ground_net(graph, n) for n in others):
out.append((ref, _cap_farads(cap)))
return out
def _sum_known_c(caps: list[tuple[str, float | None]]) -> float | None:
vals = [c for _, c in caps if c is not None]
if not vals or len(vals) != len(caps):
return None
return sum(vals)
def _fc_rc(r: float, c: float) -> float:
return 1.0 / (2.0 * math.pi * r * c)
def _fc_lc(l: float, c: float) -> float:
return 1.0 / (2.0 * math.pi * math.sqrt(l * c))
def _ic_specs_values(comp: Component) -> dict:
specs = comp.specs
values = getattr(specs, "values", None) if specs else None
return values if isinstance(values, dict) else {}
def _adc_rate_hz(graph: DesignGraph, ic_refs: list[str]) -> float | None:
for ref in ic_refs:
values = _ic_specs_values(graph.components[ref])
for key in _ADC_RATE_KEYS:
raw = values.get(key)
if raw is None:
continue
try:
return float(raw)
except (TypeError, ValueError):
continue
return None
def _dcr_limit_ohms(graph: DesignGraph, ic_refs: list[str]) -> float | None:
for ref in ic_refs:
values = _ic_specs_values(graph.components[ref])
for key in _DCR_MAX_KEYS:
raw = values.get(key)
if raw is None:
continue
try:
return float(raw)
except (TypeError, ValueError):
continue
return None
def _ic_refs_on(graph: DesignGraph, *nets: str) -> list[str]:
refs: list[str] = []
for net in nets:
for r in graph.components_on_net(net):
c = graph.components.get(r)
if c and c.component_type == ComponentType.IC and r not in refs:
refs.append(r)
return refs
def _analog_net(
graph: DesignGraph,
constraints_map: dict[str, ComponentConstraints],
*nets: str,
) -> str | None:
for net in nets:
if _ANALOG_RE.search(net or ""):
return net
for ref in _ic_refs_on(graph, net):
cons = _match_constraints(graph.components[ref].mpn or "", constraints_map)
for pin_num, pin_net in graph.components[ref].pins.items():
if pin_net != net:
continue
if _ANALOG_RE.search(net):
return net
for tok in _pin_name_tokens(cons, pin_num):
if _ANALOG_RE.search(tok):
return net
return None
def _filter_finding(
*,
kind: str,
fc: float | None,
designator: str,
mpn: str,
net: str,
extra_why: str,
adc_hz: float | None,
) -> Finding:
if fc is None:
return Finding(
designator=designator,
mpn=mpn,
aspect="filter",
source="filter_check",
status="INFO",
finding=f"{kind} filter on '{net}' ({designator}); fc unknown (missing L/C/R value).",
why=extra_why,
recommendation="Populate passive values to compute cutoff.",
reference="netlist topology",
net=net,
pins=[designator],
rule_id="PE-FLT-001",
)
if adc_hz is not None and not (0.1 * adc_hz <= fc <= 20 * adc_hz):
return Finding(
designator=designator,
mpn=mpn,
aspect="filter",
source="filter_check",
status="WARNING",
finding=(
f"{kind} filter on '{net}' has fc ≈ {fc:.3g} Hz vs ADC/data rate "
f"{adc_hz:.3g} Hz."
),
why=extra_why + " Compared only because the IC specs list a sample/data rate.",
recommendation="Adjust R/C (or L) so fc sits nearer the sample rate, or confirm anti-alias intent.",
reference="netlist topology",
net=net,
pins=[designator],
rule_id="PE-FLT-002",
)
rec = (
"fc is within a wide band of the IC sample/data rate."
if adc_hz is not None
else "Verify fc against the analog bandwidth; no datasheet rate was present."
)
return Finding(
designator=designator,
mpn=mpn,
aspect="filter",
source="filter_check",
status="INFO",
finding=f"{kind} filter on '{net}' ({designator}), fc ≈ {fc:.3g} Hz.",
why=extra_why,
recommendation=rec,
reference="netlist topology",
net=net,
pins=[designator],
rule_id="PE-FLT-001",
)
def _emit(
findings: list[Finding],
seen: set[tuple[str, str]],
*,
kind: str,
ref: str,
net: str,
fc: float | None,
mpn: str,
extra_why: str,
adc_hz: float | None,
) -> None:
key = (kind, ref)
if key in seen:
return
seen.add(key)
findings.append(_filter_finding(
kind=kind, fc=fc, designator=ref, mpn=mpn, net=net,
extra_why=extra_why, adc_hz=adc_hz,
))
def check_filters(
graph: DesignGraph,
constraints_map: dict[str, ComponentConstraints] | None = None,
) -> list[Finding]:
cmap = constraints_map or {}
findings: list[Finding] = []
seen: set[tuple[str, str]] = set()
used_l: set[str] = set()
# T: two series L sharing a middle net that has C to GND.
for mid in sorted(graph.nets):
if _is_ground_net(graph, mid):
continue
caps = _gnd_caps(graph, mid)
if not caps:
continue
inds = [
r for r in graph.components_on_net(mid)
if (c := graph.components.get(r)) is not None
and c.component_type == ComponentType.INDUCTOR
]
if len(inds) != 2:
continue
ends: list[str] = []
ok = True
for r in inds:
pair = _two_nets(graph.components[r])
if not pair:
ok = False
break
other = pair[1] if pair[0] == mid else pair[0]
if _is_ground_net(graph, other):
ok = False
break
ends.append(other)
if not ok:
continue
lvals = [_inductor_henries(graph.components[r]) for r in inds]
c_f = _sum_known_c(caps)
l_eq = sum(lvals) if all(lvals) else None # type: ignore[arg-type]
fc = _fc_lc(l_eq, c_f) if l_eq and c_f else None
ics = _ic_refs_on(graph, mid, *ends)
_emit(
findings, seen, kind="T", ref="+".join(sorted(inds)), net=mid,
fc=fc, mpn=graph.components[inds[0]].mpn or "",
extra_why="T network (L-C-L).",
adc_hz=_adc_rate_hz(graph, ics),
)
used_l.update(inds)
for ref, comp in sorted(graph.components.items()):
if comp.component_type != ComponentType.INDUCTOR or ref in used_l:
continue
pair = _two_nets(comp)
if not pair:
continue
n1, n2 = pair
if _is_ground_net(graph, n1) or _is_ground_net(graph, n2):
continue
c1, c2 = _gnd_caps(graph, n1), _gnd_caps(graph, n2)
ferrite = _is_ferrite(comp)
lval = _inductor_henries(comp)
ics = _ic_refs_on(graph, n1, n2)
adc = _adc_rate_hz(graph, ics)
if c1 and c2:
if _is_power_net(graph, n1) and _is_power_net(graph, n2) and not ferrite:
continue
s1, s2 = _sum_known_c(c1), _sum_known_c(c2)
c_eq = None
if s1 and s2:
c_eq = 1.0 / (1.0 / s1 + 1.0 / s2)
fc = _fc_lc(lval, c_eq) if lval and c_eq else None
_emit(
findings, seen, kind="π", ref=ref, net=n1, fc=fc,
mpn=comp.mpn or "", extra_why="π network (C-L-C).", adc_hz=adc,
)
elif c1 or c2:
filt_net = n1 if c1 else n2
if _is_power_net(graph, filt_net) and not ferrite:
continue
caps = c1 or c2
c_f = _sum_known_c(caps)
fc = _fc_lc(lval, c_f) if lval and c_f else None
kind = "ferrite+C" if ferrite else "LC"
_emit(
findings, seen, kind=kind, ref=ref, net=filt_net, fc=fc,
mpn=comp.mpn or "",
extra_why="Series L/ferrite with shunt C to ground.",
adc_hz=adc,
)
analog = _analog_net(graph, cmap, n1, n2)
limit = _dcr_limit_ohms(graph, ics)
dcr = _dcr_ohms(comp)
if ferrite and analog and limit is not None and dcr is not None and dcr > limit:
findings.append(Finding(
designator=ref,
mpn=comp.mpn or "",
aspect="filter",
source="filter_check",
status="WARNING",
finding=(
f"{ref} ferrite DCR {dcr:.3g} Ω on analog net '{analog}' "
f"exceeds {limit:.3g} Ω."
),
why="Bead DCR vs the IC spec limit on an analog/ADC rail.",
recommendation="Use a lower-DCR bead specified for analog, or 0 Ω.",
reference="IC specs",
net=analog,
pins=[ref],
rule_id="PE-FLT-003",
))
for ref, comp in sorted(graph.components.items()):
if comp.component_type != ComponentType.RESISTOR:
continue
pair = _two_nets(comp)
if not pair:
continue
n1, n2 = pair
if _is_power_net(graph, n1) or _is_power_net(graph, n2):
continue
if _is_ground_net(graph, n1) or _is_ground_net(graph, n2):
continue
c1, c2 = _gnd_caps(graph, n1), _gnd_caps(graph, n2)
if bool(c1) == bool(c2):
continue
filt_net, src_net, caps = (n1, n2, c1) if c1 else (n2, n1, c2)
if _is_power_net(graph, filt_net):
continue
r_ohm = _resistor_ohms(comp)
c_f = _sum_known_c(caps)
fc = _fc_rc(r_ohm, c_f) if r_ohm and c_f else None
ics = _ic_refs_on(graph, src_net, filt_net)
_emit(
findings, seen, kind="RC", ref=ref, net=filt_net, fc=fc,
mpn=comp.mpn or "", extra_why="Series R, shunt C to ground (low-pass).",
adc_hz=_adc_rate_hz(graph, ics),
)
return findings
+546
View File
@@ -0,0 +1,546 @@
"""Topology-only functional groups for Layout F1 (routing-first floorplan).
No millimetres. Domains = primary supply-rail clusters (not transitive
POWER connectivity through converters); satellites = 1-hop neighbors
classified with role_hint; layout_rules attached from IC extraction when present.
Self-contained helpers (no import of ``validate`` / Anthropic).
"""
from __future__ import annotations
import re
from typing import Any, Literal
from pydantic import BaseModel
from backend.periscopex.models import (
CapacitorSpecs,
Component,
ComponentConstraints,
ComponentType,
DesignGraph,
NetType,
SimpleComponentSpecs,
)
from backend.periscopex.resolve_passives import _parse_spice_value
RoleHint = Literal[
"decoupling",
"bulk",
"load_cap",
"filter",
"pullup",
"series",
"divider",
"bridge",
"crystal",
"other",
]
# Roles kept in satellites / assemble_order. Unclassified "other" is dropped.
_ASSEMBLE_ROLES = frozenset({
"decoupling", "bulk", "load_cap", "filter", "pullup",
"series", "divider", "bridge", "crystal",
})
_POWER_SAT_ROLES = frozenset({"decoupling", "bulk", "filter", "pullup"})
_SKIP_OTHER_TYPES = frozenset({
ComponentType.CONNECTOR,
ComponentType.SWITCH,
ComponentType.TEST_POINT,
ComponentType.FIDUCIAL,
ComponentType.MECHANICAL,
})
# Bias / charge-pump / bootstrap nets often stay SIGNAL in the graph.
_BIAS_NET_RE = re.compile(
r"(?:^|[_/\-])(REGN|PMID|BTST|BOOT|SW|LX|BST|VREG|VLDO|VREF)"
r"(?:$|[_/\-\d])",
re.IGNORECASE,
)
_STRAP_NET_RE = re.compile(
r"(?:EN|ENABLE|RESET|NRST|BOOT|CHIP_PU|GPIO0)",
re.IGNORECASE,
)
_BULK_F = 1e-6 # >= 1 µF → bulk candidate
_XTAL_RE = re.compile(
r"(?:^|[_/])(X(?:IN|OUT)|XTAL|OSC|HFX(?:IN|OUT)|LFX(?:IN|OUT)|CLK(?:IN|OUT)?)(?:$|[_/\d])",
re.I,
)
_SUPPLY_PIN_RE = re.compile(
r"(?:^|[_/])(VDD|VCC|VDDA|VDDD|VDDIO|DVDD|AVDD|IOVDD|VDD33|VDD18|"
r"VIN|VBAT|VBUS|VCORE)(?:$|[_/\d])",
re.IGNORECASE,
)
_RAIL_PIN_RE = re.compile(r"^(?:\+?\d+V\d*)$", re.IGNORECASE)
_NOT_SUPPLY_RE = re.compile(
r"\b(VSS|GND|VEE|VOUT|VREF|SW|LX|FB|BOOT|NC|VPP)\b",
re.IGNORECASE,
)
_RANK_PREFIXES: list[tuple[str, int]] = [
("ic.mcu", 0),
("ic.mpu", 0),
("ic.fpga", 0),
("ic.soc", 0),
("ic.power", 1),
("ic.interface", 2),
("ic.protection", 3),
("ic.", 4),
]
class PlacementSatellite(BaseModel):
ref: str
component_type: str
component_subtype: str | None = None
nets: list[str] = []
hop: int = 1
role_hint: RoleHint = "other"
class PlacementIcGroup(BaseModel):
ref: str
mpn: str | None = None
component_subtype: str | None = None
rank: int = 99
nets: list[str] = []
satellites: list[PlacementSatellite] = []
layout_rules: list[dict[str, Any]] = []
assemble_order: list[str] = []
class PlacementDomain(BaseModel):
domain_id: str
power_nets: list[str] = []
ic_refs: list[str] = []
assemble_order: list[str] = []
class FunctionalGroupsReport(BaseModel):
"""Routing-first placement topology (no coordinates)."""
objective: Literal["routing"] = "routing"
domains: list[PlacementDomain] = []
groups: list[PlacementIcGroup] = []
def build_functional_groups(
graph: DesignGraph,
constraints_map: dict[str, ComponentConstraints] | None = None,
) -> FunctionalGroupsReport:
"""Build domains + per-IC satellite groups from the design graph."""
cmap = constraints_map or {}
ic_refs = [
r for r, c in graph.components.items()
if c.component_type == ComponentType.IC
]
groups: list[PlacementIcGroup] = []
for ref in sorted(ic_refs, key=lambda r: (_ic_rank(graph.components[r]), r)):
groups.append(_group_for_ic(graph, ref, cmap))
domains = _build_domains(graph, ic_refs)
by_ref = {g.ref: g for g in groups}
for dom in domains:
order: list[str] = []
ranked = sorted(
dom.ic_refs,
key=lambda r: (by_ref[r].rank if r in by_ref else 99, r),
)
for iref in ranked:
order.append(iref)
g = by_ref.get(iref)
if g:
for sat in g.satellites:
if sat.ref not in order:
order.append(sat.ref)
dom.assemble_order = order
return FunctionalGroupsReport(objective="routing", domains=domains, groups=groups)
# Alias used by the dedicated Placement pipeline (same topology artifact).
build_placement_plan = build_functional_groups
PlacementPlan = FunctionalGroupsReport
def load_capacitance_farads(comp: Component) -> float | None:
"""Crystal CL from SimpleComponentSpecs.values, if present."""
specs = comp.specs
if not isinstance(specs, SimpleComponentSpecs):
return None
raw = specs.values.get("load_capacitance_f")
if raw is None:
return None
try:
v = float(raw)
except (TypeError, ValueError):
return None
return v if v > 0 else None
def _match_constraints(
mpn: str | None,
datasheets: dict[str, ComponentConstraints],
) -> ComponentConstraints | None:
if not mpn:
return None
if mpn in datasheets:
return datasheets[mpn]
norm = re.sub(r"[/_\-\s]", "", mpn).upper()
for ds_mpn, constraints in datasheets.items():
if re.sub(r"[/_\-\s]", "", ds_mpn).upper() == norm:
return constraints
return None
def _ic_rank(comp: Component) -> int:
sub = (comp.component_subtype or "").lower()
for prefix, rank in _RANK_PREFIXES:
if sub == prefix.rstrip(".") or sub.startswith(prefix):
return rank
return 9
def _group_for_ic(
graph: DesignGraph,
ref: str,
cmap: dict[str, ComponentConstraints],
) -> PlacementIcGroup:
comp = graph.components[ref]
cons = _match_constraints(comp.mpn or comp.value, cmap)
nets = [n for n in graph.nets_of_component(ref) if not _is_ground_net(graph, n)]
power_nets = {n for n in nets if _is_power_net(graph, n)}
primary = _primary_supply_net(comp, power_nets)
sat_map: dict[str, PlacementSatellite] = {}
for net_name, others in graph.neighbors(ref).items():
if _is_ground_net(graph, net_name):
continue
for oref in others:
if oref == ref or oref in sat_map:
continue
other = graph.components.get(oref)
if not other or other.component_type == ComponentType.IC:
continue
role = _role_hint(graph, comp, cons, other, net_name)
sat_nets = {n for n in other.pins.values() if n}
# Drop power-role parts that sit on a *different* named power rail
# (LDO must not inherit VSYS input caps). Strap/bias caps with no
# typed POWER net still attach.
if role in _POWER_SAT_ROLES and primary:
sat_power = {n for n in sat_nets if _is_power_net(graph, n)}
if sat_power and primary not in sat_power:
continue
if role == "other" and other.component_type in _SKIP_OTHER_TYPES:
continue
if role not in _ASSEMBLE_ROLES:
continue
sat_map[oref] = PlacementSatellite(
ref=oref,
component_type=other.component_type.value,
component_subtype=other.component_subtype,
nets=sorted(sat_nets),
hop=1,
role_hint=role,
)
# Cap enhancement: only on the primary supply rail (when known).
supply_nets = [primary] if primary else []
if not supply_nets:
supply_nets = [
n for pin_num, n in comp.pins.items()
if n and not _is_ground_net(graph, n)
and _is_ic_supply_pin(graph, cons, pin_num, n)
]
for net_name in supply_nets:
if not net_name or _is_ground_net(graph, net_name):
continue
for cref in graph.capacitors_on_net(net_name):
if cref == ref:
continue
cap = graph.components.get(cref)
if not cap:
continue
others = {n for n in cap.pins.values() if n != net_name}
if not any(_is_ground_net(graph, n) for n in others):
continue
farads = _cap_farads(cap)
role: RoleHint = "bulk" if farads is not None and farads >= _BULK_F else "decoupling"
existing = sat_map.get(cref)
if existing is None or existing.role_hint in ("other", "series"):
sat_map[cref] = PlacementSatellite(
ref=cref,
component_type=cap.component_type.value,
component_subtype=cap.component_subtype,
nets=sorted({n for n in cap.pins.values() if n}),
hop=1,
role_hint=role,
)
satellites = sorted(sat_map.values(), key=lambda s: (_role_sort(s.role_hint), s.ref))
assemble = [ref] + [s.ref for s in satellites]
rules: list[dict[str, Any]] = list(cons.layout_rules) if cons and cons.layout_rules else []
return PlacementIcGroup(
ref=ref,
mpn=comp.mpn,
component_subtype=comp.component_subtype or (cons.component_subtype if cons else None),
rank=_ic_rank(comp),
nets=sorted(nets),
satellites=satellites,
layout_rules=rules,
assemble_order=assemble,
)
def _role_sort(role: RoleHint) -> int:
order = [
"decoupling", "bulk", "load_cap", "crystal", "filter",
"pullup", "divider", "series", "bridge", "other",
]
try:
return order.index(role)
except ValueError:
return 99
def _role_hint(
graph: DesignGraph,
ic: Component,
cons: ComponentConstraints | None,
other: Component,
via_net: str,
) -> RoleHint:
if other.component_type == ComponentType.CRYSTAL:
return "crystal"
if other.component_type == ComponentType.CAPACITOR:
if _looks_xtal_net(via_net) or _ic_pin_is_xtal(cons, via_net, ic):
return "load_cap"
pin_nets = {n for n in other.pins.values() if n}
gnd_nets = {n for n in pin_nets if _is_ground_net(graph, n)}
live = [n for n in pin_nets if n not in gnd_nets]
ic_nets = {n for n in ic.pins.values() if n}
# Bootstrap / flying cap between two pins of this IC.
if len(live) == 2 and all(n in ic_nets for n in live):
return "bridge"
# Cap to GND on an IC pin / bias / strap / power net → local bypass.
if gnd_nets and len(live) == 1:
net = live[0]
if (
net in ic_nets
or _is_power_net(graph, net)
or _net_is_ic_supply(graph, ic, cons, net)
or _BIAS_NET_RE.search(net or "")
or _STRAP_NET_RE.search(net or "")
):
farads = _cap_farads(other)
return "bulk" if farads is not None and farads >= _BULK_F else "decoupling"
return "other"
if other.component_type == ComponentType.INDUCTOR:
return "filter"
if other.component_type == ComponentType.RESISTOR:
nets = list(dict.fromkeys(n for n in other.pins.values() if n))
if len(nets) == 2:
a, b = nets
if _is_power_net(graph, a) or _is_power_net(graph, b) or (
_BIAS_NET_RE.search(a or "") or _BIAS_NET_RE.search(b or "")
):
if _is_ground_net(graph, a) or _is_ground_net(graph, b):
return "divider"
return "pullup"
ic_nets = set(ic.pins.values())
if a in ic_nets and b in ic_nets:
return "bridge"
if a in ic_nets or b in ic_nets:
# Set resistor / NTC leg to GND stays series (placement-local).
return "series"
return "other"
return "other"
def _looks_xtal_net(name: str) -> bool:
return bool(_XTAL_RE.search(name or ""))
def _ic_pin_is_xtal(
cons: ComponentConstraints | None,
net_name: str,
ic: Component,
) -> bool:
for pin_num, n in ic.pins.items():
if n != net_name:
continue
tokens = _pin_name_tokens(cons, pin_num)
if any(_XTAL_RE.search(t) for t in tokens):
return True
return _looks_xtal_net(net_name)
def _net_is_ic_supply(
graph: DesignGraph,
ic: Component,
cons: ComponentConstraints | None,
net_name: str,
) -> bool:
for pin_num, n in ic.pins.items():
if n == net_name and _is_ic_supply_pin(graph, cons, pin_num, net_name):
return True
return _is_power_net(graph, net_name)
_UPSTREAM_BUS_RE = re.compile(
r"(?:^|[_/\-])(VBUS|VBAT|VIN|VCHG|VAC|VPH)(?:$|[_/\-\d])",
re.IGNORECASE,
)
_OUTPUT_BUS_RE = re.compile(
r"(?:^|[_/\-])(VSYS|VOUT|VREG)(?:$|[_/\-\d])",
re.IGNORECASE,
)
_REGULATED_RAIL_RE = re.compile(r"^\+?\d+V\d*", re.IGNORECASE)
def _primary_supply_net(comp: Component, power_nets: set[str]) -> str | None:
"""Pick one supply rail per IC so converters do not merge the whole board.
Consumers prefer regulated digital rails (3V3 / VDD). Power ICs prefer
output-ish nets (VSYS / VOUT / regulated) over upstream buses (VBUS / VIN).
"""
if not power_nets:
return None
sub = (comp.component_subtype or "").lower()
is_power_ic = sub.startswith("ic.power")
def score(name: str) -> tuple[int, str]:
u = name.upper()
s = 0
if _UPSTREAM_BUS_RE.search(u):
s -= 100
if _OUTPUT_BUS_RE.search(u):
s += 50
if _REGULATED_RAIL_RE.match(u):
s += 40
if "3V3" in u or "3.3V" in u:
s += 25
elif re.search(r"1V\d+|1\.?\d+V", u):
s += 10 # core rails still regulated, but secondary to I/O
if "VDD" in u or "VCC" in u:
s += 15
if is_power_ic:
if _UPSTREAM_BUS_RE.search(u):
s -= 40
if _OUTPUT_BUS_RE.search(u) or _REGULATED_RAIL_RE.match(u):
s += 30
return (s, name)
return max(power_nets, key=score)
def _domain_id_for_rail(rail: str) -> str:
safe = re.sub(r"[^A-Za-z0-9]+", "_", rail or "").strip("_")
return f"domain_{safe}" if safe else "domain_unknown"
def _build_domains(graph: DesignGraph, ic_refs: list[str]) -> list[PlacementDomain]:
"""Cluster ICs by primary supply rail (not union-find across converters)."""
power_by_ic: dict[str, set[str]] = {}
for ref in ic_refs:
nets: set[str] = set()
for n in graph.nets_of_component(ref):
if _is_power_net(graph, n) and not _is_ground_net(graph, n):
nets.add(n)
power_by_ic[ref] = nets
by_rail: dict[str, list[str]] = {}
no_rail: list[str] = []
for ref in ic_refs:
primary = _primary_supply_net(graph.components[ref], power_by_ic[ref])
if primary is None:
no_rail.append(ref)
else:
by_rail.setdefault(primary, []).append(ref)
domains: list[PlacementDomain] = []
for rail, members in sorted(by_rail.items(), key=lambda x: x[0].upper()):
domains.append(PlacementDomain(
domain_id=_domain_id_for_rail(rail),
power_nets=[rail],
ic_refs=sorted(members),
))
if no_rail:
domains.append(PlacementDomain(
domain_id="domain_unpowered",
power_nets=[],
ic_refs=sorted(no_rail),
))
return domains
def _pin_name_tokens(cons: ComponentConstraints | None, pin_num: str) -> list[str]:
if not cons:
return []
pin = cons.pin_by_number(pin_num)
if not pin or not pin.name:
return []
return [t.strip() for t in re.split(r"[/,]", pin.name) if t.strip()]
def _looks_like_supply(text: str) -> bool:
t = (text or "").strip()
if not t:
return False
if _NOT_SUPPLY_RE.search(t) and not _SUPPLY_PIN_RE.search(t):
return False
return bool(_SUPPLY_PIN_RE.search(t) or _RAIL_PIN_RE.match(t))
def _is_ic_supply_pin(
graph: DesignGraph,
cons: ComponentConstraints | None,
pin_num: str,
net_name: str,
) -> bool:
tokens = _pin_name_tokens(cons, pin_num)
if tokens:
return any(_looks_like_supply(t) for t in tokens)
if _looks_like_supply(net_name or ""):
return True
net = graph.nets.get(net_name)
return bool(net and net.net_type == NetType.POWER)
def _is_ground_net(graph: DesignGraph, name: str) -> bool:
net = graph.nets.get(name)
if net and net.net_type == NetType.GROUND:
return True
u = name.upper().replace("-", "_")
return u in ("GND", "VSS", "AGND", "DGND", "PGND", "GNDA", "GNDD") or (
u.startswith("GND") or u.endswith("_GND") or u.endswith("_VSS")
)
def _is_power_net(graph: DesignGraph, name: str) -> bool:
net = graph.nets.get(name)
if net and net.net_type == NetType.POWER:
return True
return bool(re.match(r"^\+?\d+V\d*", (name or "").upper()))
def _cap_farads(comp: Component) -> float | None:
specs = comp.specs
if isinstance(specs, CapacitorSpecs) and specs.value_farads > 0:
return float(specs.value_farads)
raw = (comp.value or "").strip()
if not raw:
return None
try:
v = _parse_spice_value(raw)
except ValueError:
return None
return v if v > 0 else None
+444
View File
@@ -0,0 +1,444 @@
"""Build a DesignGraph deterministically from netlist + BOM + extracted datasheets."""
from __future__ import annotations
import json
import re
from pathlib import Path
from backend.periscopex.utils import safe_mpn
from backend.periscopex.models import (
CadIndexEntry,
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.periscopex.parsers import parse_bom, parse_netlist_any
from backend.periscopex.resolve_passives import SkippedItem, resolve_bom, resolved_to_specs
# ---------------------------------------------------------------------------
# Component type classification
# ---------------------------------------------------------------------------
_PREFIX_TYPE: dict[str, ComponentType] = {
"R": ComponentType.RESISTOR,
"RN": ComponentType.RESISTOR,
"C": ComponentType.CAPACITOR,
"L": ComponentType.INDUCTOR,
"FB": 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
# KiCad-style rails: 3V3_DIGITAL, 1V8_SI4684, 5V_USB (not I2C1-SCL-3V3).
if re.match(r"^\d+V\d*", upper):
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,
pcb_path: str | Path | 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
When ``pcb_path`` points at a ``.kicad_pcb``, pad nets from the board replace
schematic-derived connectivity (KiCad board nets are authoritative).
"""
# 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)
bom_fields = {}
for ref, entry in bom.items():
row = {"mpn": entry.get("mpn"), "value": entry.get("value", "")}
if "dnp" in entry:
row["dnp"] = entry.get("dnp")
if entry.get("variant") is not None:
row["variant"] = entry.get("variant")
bom_fields[ref] = row
schematic_fields: dict[str, dict] = {}
parts, raw_nets, fmt = parse_netlist_any(
netlist_path,
known_refs=set(bom.keys()),
include_subdesigns=include_subdesigns,
)
if pcb_path is not None:
pcb = Path(pcb_path)
if pcb.is_file():
from backend.periscopex.parsers_kicad_pcb import nets_from_pcb, parse_kicad_pcb
layout = parse_kicad_pcb(pcb)
pcb_nets = nets_from_pcb(layout)
if pcb_nets:
raw_nets = pcb_nets
for ref, fp in layout.footprints.items():
parts.setdefault(ref, fp.footprint or "")
if fmt.startswith("kicad"):
from backend.periscopex.parsers_kicad import kicad_part_fields
for ref, extra in kicad_part_fields(netlist_path).items():
schematic_fields[ref] = {
"mpn": extra.get("mpn"),
"value": extra.get("value", ""),
"cad_uuid": extra.get("cad_uuid") or "",
"cad_sheet": extra.get("cad_sheet") or "",
}
entry = bom.setdefault(
ref,
{"value": "", "footprint": "", "mpn": None, "lcsc": None, "datasheet_url": None},
)
if extra.get("mpn") and (
not entry.get("mpn") or entry.get("mpn") == entry.get("value")
):
entry["mpn"] = extra["mpn"]
if extra.get("lcsc") and not entry.get("lcsc"):
entry["lcsc"] = extra["lcsc"]
if extra.get("value") and not entry.get("value"):
entry["value"] = extra["value"]
if extra.get("footprint") and not entry.get("footprint"):
entry["footprint"] = extra["footprint"]
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") or None
if not mpn and _classify_component(ref, footprint) == ComponentType.IC:
mpn = (value or "").strip() or None
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,
)
cad_index: dict[str, CadIndexEntry] = {}
for ref, extra in schematic_fields.items():
uuid = extra.get("cad_uuid") or ""
sheet = extra.get("cad_sheet") or ""
if uuid or sheet:
cad_index[ref] = CadIndexEntry(uuid=uuid, sheet=sheet)
return DesignGraph(
components=components,
nets=nets,
bom_fields=bom_fields,
schematic_fields=schematic_fields,
cad_index=cad_index,
)
+109
View File
@@ -0,0 +1,109 @@
"""HF decoupling coverage — bulk without a small ceramic.
Without a switching frequency this does not invent a Z(f) target.
INFO only: HF coverage depends on a ~100 nF close to the pin.
"""
from __future__ import annotations
from backend.periscopex.models import ComponentType, DesignGraph, Finding, NetType
from backend.periscopex.passive_rail_check import (
_cap_farads,
_is_ground_net,
_is_ic_supply_pin,
_is_nc_net,
_is_regulator_output_pin,
_pin_label,
)
from backend.periscopex.validate import _match_constraints
_BULK_MIN_F = 1e-6
_HF_MAX_F = 1e-6
_HF_MIN_F = 1e-9
def _esl_hint(footprint: str) -> str:
fp = (footprint or "").upper()
if "0402" in fp:
return "typical ESL ~0.4 nH (0402 stima)"
if "0603" in fp:
return "typical ESL ~0.6 nH (0603 stima)"
if "0805" in fp:
return "typical ESL ~0.8 nH (0805 stima)"
return "ESL depends on package (stima)"
def _valued_gnd_caps(graph: DesignGraph, net_name: str) -> list[tuple[str, float]]:
out: list[tuple[str, float]] = []
unknown = False
for ref in graph.capacitors_on_net(net_name):
cap = graph.components[ref]
others = {n for n in cap.pins.values() if n != net_name}
if not any(_is_ground_net(graph, n) for n in others):
continue
farads = _cap_farads(cap)
if farads is None:
unknown = True
continue
out.append((ref, farads))
if unknown:
return []
return out
def check_hf_decoupling_coverage(
graph: DesignGraph,
constraints_map: dict,
) -> list[Finding]:
findings: list[Finding] = []
seen: set[str] = set()
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)
for pin_num, net_name in sorted(comp.pins.items(), key=lambda x: str(x[0])):
if net_name in seen or _is_nc_net(net_name):
continue
is_rail = _is_ic_supply_pin(graph, cons, pin_num, net_name) or (
_is_regulator_output_pin(cons, pin_num)
)
if not is_rail:
continue
net = graph.nets.get(net_name)
if net and net.net_type == NetType.GROUND:
continue
seen.add(net_name)
caps = _valued_gnd_caps(graph, net_name)
if not caps:
continue
has_bulk = any(c >= _BULK_MIN_F for _, c in caps)
has_hf = any(_HF_MIN_F <= c < _HF_MAX_F for _, c in caps)
if not (has_bulk and not has_hf):
continue
bulk_ref = next(r for r, c in caps if c >= _BULK_MIN_F)
fp = graph.components[bulk_ref].footprint
pin_label = _pin_label(cons, pin_num, net_name)
findings.append(Finding(
designator=ref,
mpn=comp.mpn or "",
aspect="decoupling",
source="hf_coverage_check",
status="INFO",
finding=(
f"{ref} net '{net_name}' ({pin_label}) has bulk capacitance "
f"but no ~100 nF ceramic for HF."
),
why=(
f"Parallel Z(f) of large C is inductive above a few hundred "
f"kHz ({_esl_hint(fp)}). Without f_sw this is not an Ω target."
),
recommendation=(
f"Add a 10100 nF ceramic from '{net_name}' to ground near "
f"{ref}, in parallel with the bulk cap."
),
reference="netlist topology (stima)",
net=net_name,
pins=[f"{ref}.{pin_num}"],
rule_id="PE-ESR-001",
))
return findings
+182
View File
@@ -0,0 +1,182 @@
"""Periscope facade over ImpedanceFinder's closed-form Z0 solver.
All Z0 numbers come from ImpedenceFinder (`vendor/impedancefinder`,
Hammerstad-Jensen / Cohn as in KiCad pcb_calculator). This module only
validates geometry, inverts width for a target Z, and exports KiCad
custom-rule advice. It never emits Findings. CPWG is not implemented
upstream — we raise instead of inventing a number.
"""
from __future__ import annotations
from dataclasses import dataclass
from backend.vendor_path import ensure_impedancefinder
ensure_impedancefinder()
from impedancefinder import zsolver
class GeometryError(ValueError):
"""Trace geometry is missing, non-physical, or unsupported."""
@dataclass(frozen=True)
class TraceGeometry:
h: float
er: float
t: float
w: float | None = None
s: float | None = None
@dataclass(frozen=True)
class ImpedanceResult:
kind: str
w_mm: float | None = None
s_mm: float | None = None
z0: float | None = None
zodd: float | None = None
zeven: float | None = None
zdiff: float | None = None
formula: str = "impedancefinder"
def _require_positive(name: str, value: float | None) -> float:
if value is None or value <= 0:
raise GeometryError(f"{name} must be > 0")
return float(value)
def microstrip_z0(geo: TraceGeometry) -> float:
h = _require_positive("h", geo.h)
er = _require_positive("er", geo.er)
w = _require_positive("w", geo.w)
t = geo.t
if t < 0:
raise GeometryError("t must be >= 0")
return zsolver.microstrip_z0(w, h, er, t)
def stripline_z0(geo: TraceGeometry) -> float:
h = _require_positive("h", geo.h)
er = _require_positive("er", geo.er)
w = _require_positive("w", geo.w)
t = _require_positive("t", geo.t)
try:
return zsolver.stripline_z0(w, h, er, t)
except ValueError as exc:
raise GeometryError(str(exc)) from exc
def coupled_diff_z(geo: TraceGeometry) -> tuple[float, float, float]:
"""Return (Zodd, Zeven, Zdiff) via ImpedanceFinder IPC-2141A odd-mode."""
s = _require_positive("s", geo.s)
h = _require_positive("h", geo.h)
z0 = microstrip_z0(geo)
zdiff = zsolver.diff_microstrip_z0(
_require_positive("w", geo.w), h, s, geo.er, geo.t
)
zodd = zdiff / 2.0
zeven = 2.0 * z0 - zodd
return (zodd, zeven, zdiff)
def cpw_z0(geo: TraceGeometry) -> float:
_require_positive("h", geo.h)
_require_positive("er", geo.er)
_require_positive("w", geo.w)
_require_positive("s", geo.s)
try:
return zsolver.cpwg_z0(geo.w, geo.h, geo.s, geo.er, geo.t)
except NotImplementedError as exc:
raise GeometryError(str(exc)) from exc
def solve_width(
kind: str,
target_z: float,
h: float,
er: float,
t: float,
s: float | None = None,
) -> float:
_require_positive("target_z", target_z)
_require_positive("h", h)
_require_positive("er", er)
if kind == "stripline":
_require_positive("t", t)
elif t < 0:
raise GeometryError("t must be >= 0")
def z_of(w: float) -> float:
geo = TraceGeometry(h=h, er=er, t=t, w=w, s=s)
if kind == "microstrip":
return microstrip_z0(geo)
if kind == "stripline":
return stripline_z0(geo)
if kind == "diff":
return coupled_diff_z(geo)[2]
if kind == "cpw":
return cpw_z0(geo)
raise GeometryError(f"unknown kind {kind}")
lo, hi = 0.01 * h, 40.0 * h
z_lo, z_hi = z_of(lo), z_of(hi)
if not (min(z_lo, z_hi) <= target_z <= max(z_lo, z_hi)):
raise GeometryError("target_z is outside the solvable width range")
for _ in range(48):
mid = 0.5 * (lo + hi)
zm = z_of(mid)
if zm > target_z:
lo = mid
else:
hi = mid
return 0.5 * (lo + hi)
def stackup_targets(
h: float,
er: float,
t: float,
s: float,
) -> dict[str, ImpedanceResult]:
w50 = solve_width("microstrip", 50.0, h, er, t)
w90 = solve_width("diff", 90.0, h, er, t, s=s)
w100 = solve_width("diff", 100.0, h, er, t, s=s)
z50 = microstrip_z0(TraceGeometry(h=h, er=er, t=t, w=w50))
_, _, zd90 = coupled_diff_z(TraceGeometry(h=h, er=er, t=t, w=w90, s=s))
_, _, zd100 = coupled_diff_z(TraceGeometry(h=h, er=er, t=t, w=w100, s=s))
return {
"microstrip_50": ImpedanceResult(kind="microstrip", w_mm=w50, z0=z50),
"diff_90": ImpedanceResult(kind="diff", w_mm=w90, s_mm=s, zdiff=zd90),
"diff_100": ImpedanceResult(kind="diff", w_mm=w100, s_mm=s, zdiff=zd100),
}
def export_kicad_dru(targets: dict[str, ImpedanceResult]) -> str:
"""KiCad custom-rule advice. The user applies it; Periscope does not DRC the PCB."""
lines = [
"(version 1)",
"# Periscope impedance advice (ImpedanceFinder solver) — apply in pcbnew.",
]
mapping = (
("microstrip_50", "PERISCOPE_50OHM", "50Ohm"),
("diff_90", "PERISCOPE_90OHM_USB", "90Ohm"),
("diff_100", "PERISCOPE_100OHM_DIFF", "100Ohm"),
)
for key, rule, netclass in mapping:
r = targets[key]
w = r.w_mm
if w is None:
continue
lines.append("")
lines.append(f"(rule {rule}")
lines.append(f' (constraint track_width (min {w:.4f}mm) (opt {w:.4f}mm) (max {w:.4f}mm))')
if r.s_mm:
lines.append(
f" (constraint diff_pair_gap (min {r.s_mm:.4f}mm) "
f"(opt {r.s_mm:.4f}mm) (max {r.s_mm:.4f}mm))"
)
lines.append(f' (condition "A.NetClass == \'{netclass}\'"))')
return "\n".join(lines) + "\n"
+156
View File
@@ -0,0 +1,156 @@
"""ImpedenceFinder net analysis on specified PCB traces.
Walks sampled points on named nets (net_walk + planes + zsolver).
Stackup and widths come from the board (or explicit LayoutStackup).
No invented εr/h; missing stackup or empty net list skips.
"""
from __future__ import annotations
from dataclasses import asdict
from backend.vendor_path import ensure_impedancefinder
ensure_impedancefinder()
from impedancefinder import net_analysis, report
from impedancefinder.model import (
BoardData,
DielectricLayer,
Point2D,
Stackup,
TraceSegment,
ViaSpan,
ZonePolygon,
)
from backend.periscopex.impedance import GeometryError
from backend.periscopex.models import DesignGraph, LayoutGraph, NetType
def _stackup(layout: LayoutGraph) -> Stackup:
raw = layout.stackup
if raw is None:
raise GeometryError("PCB has no stackup (copper + dielectric εr/h)")
t = raw.copper_thickness_mm
if t is None or t <= 0:
raise GeometryError("PCB stackup has no copper thickness")
return Stackup(
copper_layer_names=tuple(raw.copper_layers),
dielectrics=tuple(
DielectricLayer(name=d.name, er=d.er, height_mm=d.height_mm)
for d in raw.dielectrics
),
copper_thickness_mm=t,
)
def layout_to_board_data(layout: LayoutGraph) -> BoardData:
stackup = _stackup(layout)
segments: list[TraceSegment] = []
for s in layout.segments:
if not s.net or s.width <= 0:
continue
segments.append(TraceSegment(
net=s.net,
layer=s.layer,
start=Point2D(s.start[0], s.start[1]),
end=Point2D(s.end[0], s.end[1]),
width_mm=s.width,
))
vias: list[ViaSpan] = []
layers = stackup.copper_layer_names
if len(layers) >= 2:
top, bot = layers[0], layers[-1]
for v in layout.vias:
if not v.net or v.drill is None or v.drill <= 0:
continue
vias.append(ViaSpan(
net=v.net,
position=Point2D(v.x, v.y),
top_layer=top,
bottom_layer=bot,
drill_mm=v.drill,
))
zones: list[ZonePolygon] = []
for z in layout.zones:
rings = tuple(
tuple(Point2D(x, y) for x, y in ring)
for ring in z.outlines
if len(ring) >= 3
)
if rings:
zones.append(ZonePolygon(net=z.net, layer=z.layer, outlines_mm=rings))
return BoardData(
segments=tuple(segments),
vias=tuple(vias),
zone_polygons=tuple(zones),
copper_layer_names=stackup.copper_layer_names,
stackup=stackup,
outline=None,
)
def analyze_specified_nets(
layout: LayoutGraph,
net_names: list[str],
pitch_mm: float,
) -> list[dict]:
"""Analyze only the named nets. Empty names → []. Missing net → error row."""
if pitch_mm <= 0:
raise GeometryError("pitch_mm must be > 0")
wanted = [n.strip() for n in net_names if n and n.strip()]
if not wanted:
return []
board = layout_to_board_data(layout)
stackup = board.stackup
assert stackup is not None
rows: list[dict] = []
for name in wanted:
segs = net_analysis.segments_for(board, name)
if not segs:
rows.append({"net_name": name, "error": "no segments on this net"})
continue
result = net_analysis.analyze_net(board, stackup, name, pitch_mm)
summary = report.summarize_net(name, board, result)
row = asdict(summary)
row["sample_count"] = len(result.samples)
rows.append(row)
return rows
# ImpedenceFinder net_walk sample interval (mm). Same as vendor
# tests/test_net_walk.py pitch_mm=1.0 — not a Z0 target.
NET_WALK_PITCH_MM = 1.0
def nets_needed(layout: LayoutGraph, graph: DesignGraph | None) -> list[str]:
"""Routed copper that is not a power/ground net in the schematic."""
routed = {s.net for s in layout.segments if s.net and s.width > 0}
needed: list[str] = []
for name in sorted(routed):
if graph is not None:
net = graph.nets.get(name)
if net is not None and net.net_type in (NetType.POWER, NetType.GROUND):
continue
needed.append(name)
return needed
def analyze_where_needed(
layout: LayoutGraph,
graph: DesignGraph | None = None,
pitch_mm: float = NET_WALK_PITCH_MM,
) -> dict:
"""Pipeline entry: skip without stackup or without routed signal nets."""
if pitch_mm <= 0:
raise GeometryError("pitch_mm must be > 0")
if layout.stackup is None:
return {"pitch_mm": pitch_mm, "nets": [], "skipped": "no stackup"}
names = nets_needed(layout, graph)
if not names:
return {"pitch_mm": pitch_mm, "nets": [], "skipped": "no routed signal nets"}
return {
"pitch_mm": pitch_mm,
"nets": analyze_specified_nets(layout, names, pitch_mm),
"skipped": None,
}
@@ -0,0 +1,56 @@
"""Open-drain / on-die pull-up pins from extracted internal_features."""
from __future__ import annotations
from backend.periscopex.models import ComponentConstraints, DesignGraph, Finding
from backend.periscopex.passive_rail_check import (
_pin_name_tokens,
_resistor_to_power,
)
from backend.periscopex.validate import _match_constraints
def check_internal_features(
graph: DesignGraph,
constraints_map: dict[str, ComponentConstraints] | None = None,
) -> list[Finding]:
cmap = constraints_map or {}
findings: list[Finding] = []
for ref, comp in sorted(graph.components.items()):
cons = _match_constraints(comp.mpn or comp.value, cmap)
feats = cons.internal_features if cons else None
if not feats or not feats.pullup_pins:
continue
for pin_name in feats.pullup_pins:
net = None
for pin_num, n in comp.pins.items():
tokens = _pin_name_tokens(cons, pin_num)
names = tokens or [n or "", str(pin_num)]
if any(
t.upper() == pin_name.upper() or (n or "").upper() == pin_name.upper()
for t in names
):
net = n
break
if not net:
continue
if _resistor_to_power(graph, net):
continue
findings.append(Finding(
designator=ref,
mpn=comp.mpn or "",
aspect="internal_features",
source="internal_features_check",
status="WARNING",
finding=(
f"{ref} {pin_name} is listed as needing an external pull-up "
f"and net '{net}' has none."
),
why="internal_features.pullup_pins from the datasheet block diagram.",
recommendation="Add a pull-up to the I/O rail, or confirm an on-die pull is enabled.",
reference="internal_features",
net=net,
pins=[f"{ref}.{pin_name}"],
rule_id="PE-INT-001",
))
return findings
+93
View File
@@ -0,0 +1,93 @@
"""Validate datasheet layout_rules. Distances stay null unless numeric."""
from __future__ import annotations
from typing import Any
from packaging.version import Version
KNOWN_KINDS = frozenset({"decoupling_proximity", "thermal_via", "keepout", "length_match"})
def _num(v: Any) -> float | None:
if v is None or v is False:
return None
if isinstance(v, bool):
return None
if isinstance(v, (int, float)):
return float(v)
try:
return float(str(v).strip())
except (TypeError, ValueError):
return None
def has_any_layout_rule(raw: object) -> bool:
"""True when extraction already produced at least one structured rule."""
if not isinstance(raw, list):
return False
for row in raw:
if isinstance(row, dict) and str(row.get("kind") or "").strip() in KNOWN_KINDS:
return True
return False
def needs_layout_rules_refresh(
data: dict,
*,
min_scan_version: str,
) -> bool:
"""True when layout_rules are empty and the extract predates the scan version.
After a successful extract at ``min_scan_version`` or newer, an empty
``layout_rules`` list means the datasheet had no guidance — do not loop.
"""
if has_any_layout_rule(data.get("layout_rules")):
return False
ver = str(data.get("model_version") or "0.0.0")
if not min_scan_version or min_scan_version == "0.0.0":
return False
try:
return Version(ver) < Version(min_scan_version)
except Exception:
return True
def validate_layout_rules(raw: list | None) -> tuple[list[dict], list[str]]:
"""Return (normalized rows, errors). Empty list is a valid skip."""
if not raw:
return [], []
if not isinstance(raw, list):
return [], ["layout_rules must be an array"]
ok: list[dict] = []
errors: list[str] = []
for i, row in enumerate(raw):
if not isinstance(row, dict):
errors.append(f"layout_rules[{i}] must be an object")
continue
kind = str(row.get("kind") or "").strip()
if kind not in KNOWN_KINDS:
errors.append(f"layout_rules[{i}] unknown kind {kind!r}")
continue
dist = _num(row.get("max_distance_mm"))
via = row.get("min_via_count")
via_i = None
if isinstance(via, int) and not isinstance(via, bool):
via_i = via
elif via is not None:
n = _num(via)
via_i = int(n) if n is not None else None
page = row.get("source_page")
page_i = int(page) if isinstance(page, int) else None
ok.append({
"kind": kind,
"pin": row.get("pin"),
"cap_value_hint": row.get("cap_value_hint"),
"max_distance_mm": dist,
"same_layer": row.get("same_layer") if isinstance(row.get("same_layer"), bool) else None,
"min_via_count": via_i,
"net_class": row.get("net_class"),
"note": row.get("note"),
"source_page": page_i,
})
return ok, errors
+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.periscopex.models import ComponentType, DesignGraph, Finding, NetType
from backend.periscopex.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",
)
+41
View File
@@ -0,0 +1,41 @@
"""Shared-library promotion gates for extracted IC JSON."""
from __future__ import annotations
import hashlib
import json
from typing import Any
def pintable_checksum(pintable: list[Any]) -> str:
"""Stable hash of pin number+name pairs (order-independent)."""
rows: list[tuple[str, str]] = []
for pin in pintable or []:
if isinstance(pin, dict):
num = str(pin.get("number") or "").strip()
name = str(pin.get("name") or "").strip()
else:
num = str(getattr(pin, "number", "") or "").strip()
name = str(getattr(pin, "name", "") or "").strip()
if num:
rows.append((num, name))
payload = json.dumps(sorted(rows), separators=(",", ":"))
return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16]
def should_promote_extraction(data: dict) -> tuple[bool, str]:
"""Return (ok, reason). Reject empty / tiny pintables from shared library."""
pins = data.get("pintable") or []
if not isinstance(pins, list) or len(pins) == 0:
return False, "empty pintable"
if len(pins) < 2:
return False, "pintable has fewer than 2 pins"
# Require at least one named pin so a number-only stub cannot poison the library.
named = 0
for pin in pins:
name = pin.get("name") if isinstance(pin, dict) else getattr(pin, "name", None)
if name and str(name).strip() and str(name).strip() != "~":
named += 1
if named == 0:
return False, "pintable has no named pins"
return True, pintable_checksum(pins)
+221
View File
@@ -0,0 +1,221 @@
"""Distributor lifecycle / RoHS — cached records only, never a guessed equivalent."""
from __future__ import annotations
import json
import re
from pathlib import Path
from pydantic import BaseModel
from backend.periscopex.models import ComponentType, DesignGraph, Finding
from backend.periscopex.utils import safe_mpn
_EOL = re.compile(
r"\b(obsolete|eol|end\s*of\s*life|discontinued|last\s*time\s*buy|ltb)\b",
re.I,
)
_NRND = re.compile(
r"\b(nrnd|not\s+for\s+new\s+designs|not\s+recommended)\b",
re.I,
)
_ACTIVE = re.compile(r"\b(active|production|recommended)\b", re.I)
_ROHS_NO = re.compile(r"\b(non[-\s]?compliant|not\s+compliant|no)\b", re.I)
_ROHS_YES = re.compile(r"\b(rohs\s*\d*\s*compliant|compliant|yes|true)\b", re.I)
_ROHS_NA = re.compile(r"\b(not\s+applicable|n/?a|exempt)\b", re.I)
class LifecycleRecord(BaseModel):
mpn: str
source: str = ""
lifecycle: str | None = None # active | nrnd | eol | unknown
rohs_compliant: bool | None = None
stock: int | None = None
lead_time: str | None = None
replacement: str | None = None
product_status_raw: str = ""
def _status_lifecycle(raw: str) -> str | None:
s = (raw or "").strip()
if not s:
return None
if _EOL.search(s):
return "eol"
if _NRND.search(s):
return "nrnd"
if _ACTIVE.search(s):
return "active"
return "unknown"
def _rohs(raw: str) -> bool | None:
s = (raw or "").strip()
if not s:
return None
if _ROHS_NA.search(s):
return None
if _ROHS_NO.search(s):
return False
if _ROHS_YES.search(s):
return True
return None
def _replacement(product: dict) -> str | None:
for key in ("ProductSubstitutions", "Substitutes", "replacement", "Replacement"):
val = product.get(key)
if not val:
continue
if isinstance(val, str) and val.strip():
return val.strip()
if isinstance(val, list) and val:
first = val[0]
if isinstance(first, str) and first.strip():
return first.strip()
if isinstance(first, dict):
for k in ("ManufacturerProductNumber", "ManufacturerPartNumber", "mpn"):
if first.get(k):
return str(first[k]).strip()
return None
def parse_distributor_product(mpn: str, product: dict, *, source: str = "digikey") -> LifecycleRecord:
"""Map a DigiKey/Mouser/LCSC product dict. Unknown fields stay None."""
status = (
product.get("ProductStatus")
or product.get("productStatus")
or product.get("partLifeCycle")
or product.get("LifecycleStatus")
or ""
)
rohs_raw = (
product.get("RoHSStatus")
or product.get("rohsStatus")
or product.get("rohs")
or ""
)
if isinstance(rohs_raw, bool):
rohs = rohs_raw
rohs_raw = "true" if rohs_raw else "false"
else:
rohs = _rohs(str(rohs_raw))
stock = product.get("QuantityAvailable")
if stock is None:
stock = product.get("stock")
try:
stock_i = int(stock) if stock is not None else None
except (TypeError, ValueError):
stock_i = None
lead = product.get("ManufacturerLeadWeeks") or product.get("lead_time") or product.get("LeadTime")
return LifecycleRecord(
mpn=mpn,
source=source,
lifecycle=_status_lifecycle(str(status)),
rohs_compliant=rohs,
stock=stock_i,
lead_time=str(lead) if lead not in (None, "") else None,
replacement=_replacement(product),
product_status_raw=str(status),
)
def load_lifecycle_dir(directory: str | Path) -> dict[str, LifecycleRecord]:
out: dict[str, LifecycleRecord] = {}
path = Path(directory)
if not path.is_dir():
return out
for f in path.glob("*.json"):
raw = json.loads(f.read_text())
rec = LifecycleRecord.model_validate(raw)
out[rec.mpn] = rec
return out
def write_lifecycle_record(directory: str | Path, rec: LifecycleRecord) -> Path:
path = Path(directory)
path.mkdir(parents=True, exist_ok=True)
dest = path / f"{safe_mpn(rec.mpn)}.json"
dest.write_text(rec.model_dump_json(indent=2) + "\n")
return dest
def _match_record(mpn: str | None, records: dict[str, LifecycleRecord]) -> LifecycleRecord | None:
if not mpn:
return None
if mpn in records:
return records[mpn]
norm = re.sub(r"[/_\-\s]", "", mpn).upper()
for key, rec in records.items():
if re.sub(r"[/_\-\s]", "", key).upper() == norm:
return rec
return None
def check_lifecycle(
graph: DesignGraph,
records: dict[str, LifecycleRecord] | None,
) -> list[Finding]:
recs = records or {}
findings: list[Finding] = []
seen: set[str] = set()
for ref, comp in sorted(graph.components.items()):
if comp.component_type in (
ComponentType.MECHANICAL, ComponentType.FIDUCIAL, ComponentType.TEST_POINT,
):
continue
mpn = (comp.mpn or "").strip()
rec = _match_record(mpn, recs)
if rec is None:
continue
if mpn in seen:
continue
seen.add(mpn)
if rec.lifecycle == "eol":
rec_txt = (
f"Distributor replacement: {rec.replacement}."
if rec.replacement else
"No distributor replacement was listed."
)
findings.append(Finding(
designator=ref,
mpn=mpn,
aspect="lifecycle",
source="lifecycle_check",
status="WARNING",
finding=f"{mpn} is EOL/obsolete ({rec.product_status_raw or 'eol'}).",
why="Distributor ProductStatus, not an LLM equivalent search.",
recommendation=rec_txt,
reference=rec.source or "distributor",
pins=[ref],
rule_id="PE-LF-001",
))
elif rec.lifecycle == "nrnd":
findings.append(Finding(
designator=ref,
mpn=mpn,
aspect="lifecycle",
source="lifecycle_check",
status="INFO",
finding=f"{mpn} is NRND ({rec.product_status_raw or 'nrnd'}).",
why="Distributor ProductStatus.",
recommendation="Prefer an Active orderable if the design is new.",
reference=rec.source or "distributor",
pins=[ref],
rule_id="PE-LF-002",
))
if rec.rohs_compliant is False:
findings.append(Finding(
designator=ref,
mpn=mpn,
aspect="lifecycle",
source="lifecycle_check",
status="WARNING",
finding=f"{mpn} is marked RoHS non-compliant.",
why="RoHS fail only when the distributor flag is explicit.",
recommendation="Choose a RoHS-compliant orderable of the same MPN family.",
reference=rec.source or "distributor",
pins=[ref],
rule_id="PE-LF-003",
))
return findings
+509
View File
@@ -0,0 +1,509 @@
"""Pydantic models for PeriscopeX: 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, model_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.periscopex.taxonomy import validate_subtype
return validate_subtype(str(v))
class InternalFeatures(BaseModel):
"""Block-diagram extras: ESD clamps, on-die pull-ups, analog switches."""
esd_clamp_pins: list[str] = []
pullup_pins: list[str] = []
analog_switch: list[str] = []
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]
internal_features: InternalFeatures | None = None
layout_rules: list[dict] = []
_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 / ferrite-bead parameters."""
specs_type: Literal["inductor"] = "inductor"
component_subtype: str | None = None # e.g. "passive.inductor" or "passive.ferrite_bead"
value_henries: float | None = None
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
impedance_ohm: float | None = None # ferrite beads: Z at test frequency
_validate_subtype = field_validator("component_subtype", mode="before")(
staticmethod(_check_subtype)
)
@model_validator(mode="after")
def _require_primary_value(self) -> InductorSpecs:
if self.component_subtype == "passive.ferrite_bead":
if self.impedance_ohm is None:
raise ValueError("ferrite bead requires impedance_ohm")
return self
if self.value_henries is None:
raise ValueError("inductor requires value_henries")
return self
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 CadIndexEntry(BaseModel):
"""KiCad symbol identity for plugin pan-and-zoom."""
uuid: str = ""
sheet: str = ""
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] = {}
# KiCad property table vs uploaded BOM (empty on PADS/EDIF).
bom_fields: dict[str, dict] = {}
schematic_fields: dict[str, dict] = {}
cad_index: dict[str, CadIndexEntry] = {}
# -- 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 (c := self.components.get(r)) is not None
and c.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; "pin_mux_check"/"led_current_check"/"supply_decoupling_check"/… = deterministic
net: str | None = None # net name for CAD telemetry / SI filters
pins: list[str] = [] # e.g. ["U3.54"] for pan-and-zoom
rule_id: str | None = None # deterministic id, e.g. PE-MUX-001
cad_sheet: str | None = None # schematic sheet filename for plugin sync
cad_uuid: str | None = None # KiCad symbol/pin uuid
variant: str | None = None # DNP / ECO / assembly variant
class ValidationReport(BaseModel):
"""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] = {}
class LayoutPad(BaseModel):
number: str
x: float
y: float
net: str = ""
class LayoutFootprint(BaseModel):
reference: str
footprint: str = ""
x: float
y: float
layer: str = ""
pads: list[LayoutPad] = []
courtyard: list[tuple[float, float]] = []
class LayoutSegment(BaseModel):
start: tuple[float, float]
end: tuple[float, float]
width: float = 0.0
layer: str = ""
net: str = ""
class LayoutVia(BaseModel):
x: float
y: float
net: str = ""
drill: float | None = None
class LayoutDielectric(BaseModel):
name: str
er: float
height_mm: float
class LayoutStackup(BaseModel):
copper_layers: list[str]
dielectrics: list[LayoutDielectric]
copper_thickness_mm: float | None = None
class LayoutZone(BaseModel):
net: str
layer: str
outlines: list[list[tuple[float, float]]] = []
class LayoutGraph(BaseModel):
"""Parsed `.kicad_pcb` geometry. Optional; schema validation does not require it."""
nets: dict[str, int] = {}
footprints: dict[str, LayoutFootprint] = {}
segments: list[LayoutSegment] = []
vias: list[LayoutVia] = []
stackup: LayoutStackup | None = None
zones: list[LayoutZone] = []
+109
View File
@@ -0,0 +1,109 @@
"""NC pintable pins must not sit on an active net with other parts."""
from __future__ import annotations
import re
from backend.periscopex.models import (
ComponentConstraints,
ComponentType,
DesignGraph,
Finding,
)
_NC_NAME_RE = re.compile(
r"^(?:n/?c|n\.c\.|nc|unconnected|no[_-]?connect|not[_-]?connected)$",
re.IGNORECASE,
)
_NC_NET_RE = re.compile(
r"^(?:n/?c|n\.c\.|nc|unconnected|no[_-]?connect|not[_-]?connected)$",
re.IGNORECASE,
)
def check_nc_pins(
graph: DesignGraph,
constraints_map: dict[str, ComponentConstraints],
) -> list[Finding]:
findings: list[Finding] = []
for ref, comp in sorted(graph.components.items()):
if comp.component_type != ComponentType.IC:
continue
cons = _match(comp.mpn or comp.value, constraints_map)
if not cons or not cons.pintable:
continue
for pin in cons.pintable:
if not _is_nc_pin_name(pin.name or ""):
continue
net_name = comp.pins.get(str(pin.number))
if not net_name:
continue
if _NC_NET_RE.match(net_name.strip()):
continue
others = [
r for r in graph.components_on_net(net_name)
if r != ref
]
if not others:
# Lone net named oddly but empty of other parts — still flag if
# the net name looks like a real signal (not floating placeholder).
if _looks_active_net(net_name):
findings.append(_finding(ref, comp.mpn or "", pin.number, pin.name, net_name, []))
continue
findings.append(_finding(ref, comp.mpn or "", pin.number, pin.name, net_name, others))
return findings
def _finding(ref, mpn, pin_num, pin_name, net, others) -> Finding:
other_s = ", ".join(others[:6]) if others else "(no other refs)"
return Finding(
designator=ref,
mpn=mpn,
aspect="connectivity",
source="nc_pin_check",
status="WARNING",
finding=(
f"{ref} pin {pin_num} ({pin_name or 'NC'}) is marked NC in the "
f"pintable but connects to net '{net}'"
+ (f" with {other_s}." if others else ".")
),
why="No-connect pins should remain unconnected or on an explicit NC net.",
recommendation="Leave the NC pin floating or disconnect the net.",
reference="pintable",
rule_id="PE-NC-001",
net=net,
pins=[f"{ref}.{pin_num}"],
)
def _is_nc_pin_name(name: str) -> bool:
t = (name or "").strip()
if not t:
return False
if _NC_NAME_RE.match(t):
return True
# Slash-separated alts: "NC/GPIO" still counts as NC-capable; only pure NC.
parts = [p.strip() for p in re.split(r"[/,]", t) if p.strip()]
return bool(parts) and all(_NC_NAME_RE.match(p) or p.upper() == "NC" for p in parts)
def _looks_active_net(name: str) -> bool:
u = (name or "").strip()
if not u or u.startswith("unconnected"):
return False
return not _NC_NET_RE.match(u)
def _match(
mpn: str | None,
datasheets: dict[str, ComponentConstraints],
) -> ComponentConstraints | None:
if not mpn:
return None
if mpn in datasheets:
return datasheets[mpn]
norm = re.sub(r"[/_\-\s]", "", mpn).upper()
for ds_mpn, constraints in datasheets.items():
if re.sub(r"[/_\-\s]", "", ds_mpn).upper() == norm:
return constraints
return None
+251
View File
@@ -0,0 +1,251 @@
"""Unpack a netlist upload: one file, several KiCad sheets, or a zip.
The hierarchical ``.kicad_sch`` parser needs sibling files on disk. A single
temp file named ``tmpXXXX.kicad_sch`` cannot see ``Sheetfile`` children.
"""
from __future__ import annotations
import io
import zipfile
from dataclasses import dataclass
from pathlib import Path
from backend.periscopex.parsers import detect_netlist_format
MAX_BUNDLE_BYTES = 30 * 1024 * 1024
_MAX_ZIP_MEMBERS = 400
_KIND = str # pads | edif | kicad_* | zip | kicad_pcb | unknown
@dataclass
class NetlistUpload:
root: Path
work_dir: Path
pcb: Path | None
extra_sch: list[Path]
bom: Path | None = None
def sniff_netlist_kind(content: bytes) -> str:
if content[:2] == b"PK":
return "zip"
head = content[:2048].decode("utf-8", errors="replace").lstrip("\ufeff").lstrip()
low = head[:40].lower()
if low.startswith("(kicad_pcb"):
return "kicad_pcb"
if low.startswith("(kicad_sch"):
return "kicad_sch"
if low.startswith("(edif"):
return "edif"
if low.startswith("(export"):
return "kicad_sexp"
if low.startswith("<?xml") or low.startswith("<export"):
return "kicad_xml"
if "*PADS-PCB*" in head.upper() or head.lstrip().startswith("*PART*"):
return "pads"
return "unknown"
def _safe_rel(name: str) -> str:
rel = name.replace("\\", "/").strip()
if not rel or rel.startswith("/") or rel.startswith("\\"):
raise ValueError(f"Rejected path: {name}")
parts = Path(rel).parts
if ".." in parts or (parts and parts[0] == ".."):
raise ValueError(f"Rejected path: {name}")
return rel
_KEEP_SUFFIX = {
".kicad_sch",
".kicad_pcb",
".kicad_pro",
".kicad_net",
".xml",
".edn",
".edif",
".edf",
".asc",
".net",
".csv",
".xlsx",
}
def _keep_zip_member(rel: str) -> bool:
parts = Path(rel).parts
if any(
p.endswith("-backups") or p.endswith(".pretty") or p.lower() in {"3dmodels", "__macosx"}
for p in parts
):
return False
return Path(rel).suffix.lower() in _KEEP_SUFFIX
def _extract_zip(data: bytes, dest: Path) -> None:
dest.mkdir(parents=True, exist_ok=True)
total = 0
kept = 0
with zipfile.ZipFile(io.BytesIO(data)) as zf:
for info in zf.infolist():
if info.is_dir():
continue
rel = _safe_rel(info.filename)
if not _keep_zip_member(rel):
continue
kept += 1
if kept > _MAX_ZIP_MEMBERS:
raise ValueError("Zip has too many schematic files")
total += max(info.file_size, 0)
if total > MAX_BUNDLE_BYTES:
raise ValueError("Zip is too large")
out = dest / rel
out.parent.mkdir(parents=True, exist_ok=True)
with zf.open(info) as src:
payload = src.read()
if len(payload) > MAX_BUNDLE_BYTES:
raise ValueError("Zip member is too large")
out.write_bytes(payload)
def _write_named(name: str, data: bytes, dest: Path) -> None:
rel = _safe_rel(name)
kind = sniff_netlist_kind(data)
if kind == "zip":
_extract_zip(data, dest)
return
out = dest / Path(rel).name
# Keep a single subdirectory when the client sent webkitRelativePath.
if "/" in rel:
out = dest / rel
out.parent.mkdir(parents=True, exist_ok=True)
else:
dest.mkdir(parents=True, exist_ok=True)
out.write_bytes(data)
def find_kicad_pcb(work: Path) -> Path | None:
hits = sorted(p for p in work.rglob("*.kicad_pcb") if p.is_file())
return hits[0] if hits else None
def find_bom(work: Path) -> Path | None:
"""Prefer a shallow BOM path (KiCad project root over nested copies)."""
hits = [
p for p in work.rglob("*")
if p.is_file() and p.suffix.lower() in {".csv", ".xlsx"}
]
if not hits:
return None
hits.sort(key=lambda p: (len(p.relative_to(work).parts), p.name.lower()))
return hits[0]
def _sheetfiles_of(path: Path) -> list[str]:
from backend.periscopex.parsers_kicad import _parse_sexp, _sheetfiles, _tag
text = path.read_text(encoding="utf-8", errors="replace")
tree = _parse_sexp(text)
if _tag(tree) != "kicad_sch":
return []
return _sheetfiles(tree)
def _pick_root(work: Path) -> Path:
schs: list[Path] = []
exported: list[Path] = []
for p in work.rglob("*"):
if not p.is_file():
continue
kind = sniff_netlist_kind(p.read_bytes()[:2048])
if kind == "kicad_sch":
schs.append(p)
elif kind in ("kicad_xml", "kicad_sexp", "edif", "pads"):
exported.append(p)
if exported:
pref = [
p for p in exported
if sniff_netlist_kind(p.read_bytes()[:2048]) in (
"kicad_xml", "kicad_sexp", "edif",
)
]
return (pref or exported)[0]
if not schs:
raise ValueError(
"No schematic found. Drop the KiCad project folder or a netlist."
)
referenced: set[Path] = set()
for p in schs:
for rel in _sheetfiles_of(p):
try:
child = (p.parent / rel.replace("\\", "/")).resolve()
except ValueError:
continue
referenced.add(child)
roots = [p for p in schs if p.resolve() not in referenced]
if not roots:
raise ValueError("Cyclic sheet includes — upload an exported KiCad netlist instead.")
pro = list(work.rglob("*.kicad_pro"))
if len(roots) > 1 and pro:
stems = {p.stem for p in pro}
matched = [r for r in roots if r.stem in stems]
if len(matched) == 1:
return matched[0]
if len(roots) > 1:
names = ", ".join(sorted(r.name for r in roots))
raise ValueError(
f"Multiple root sheets ({names}). Upload a zip of the project, "
"or the top-level .kicad_sch together with every Sheetfile child."
)
return roots[0]
def materialize_netlist_upload(
files: list[tuple[str, bytes]],
dest: Path,
) -> NetlistUpload:
"""Write uploaded bytes into ``dest`` and return the file to parse."""
if not files:
raise ValueError("No netlist file uploaded")
dest.mkdir(parents=True, exist_ok=True)
total = sum(len(b) for _n, b in files)
if total > MAX_BUNDLE_BYTES:
raise ValueError("Upload is too large")
if len(files) == 1:
name, data = files[0]
kind = sniff_netlist_kind(data)
if kind == "kicad_pcb":
raise ValueError(
"This is a board file. Drop the KiCad project folder, or put "
"the .kicad_pcb on the optional board step."
)
if kind == "unknown" and not name.lower().endswith(".zip"):
raise ValueError(
"Not a netlist. Drop the KiCad project folder, a zip, or a "
"PADS / EDIF / KiCad netlist."
)
for name, data in files:
_write_named(name, data, dest)
root = _pick_root(dest)
pcb = find_kicad_pcb(dest)
bom = find_bom(dest)
extras = [
p for p in work_sch_files(dest)
if p.resolve() != root.resolve()
]
return NetlistUpload(
root=root, work_dir=dest, pcb=pcb, extra_sch=extras, bom=bom,
)
def work_sch_files(dest: Path) -> list[Path]:
return sorted(p for p in dest.rglob("*.kicad_sch") if p.is_file())
+313
View File
@@ -0,0 +1,313 @@
"""Pure parsers for PADS-PCB netlists and KiCad BOM CSV files."""
from __future__ import annotations
import csv
import re
from pathlib import Path
from typing import Literal
NetlistFormat = Literal["pads", "edif", "kicad_xml", "kicad_sexp", "kicad_sch"]
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 the format.
EDIF starts with ``(edif``; KiCad XML with ``<export`` / ``<?xml``;
KiCad s-expr netlist with ``(export``; schematic with ``(kicad_sch``.
PADS-PCB ASCII (``*PADS-PCB*``) is the default when no marker is found.
"""
if isinstance(content, bytes):
text = content[:2048].decode("utf-8", errors="replace")
else:
text = content[:2048]
head = text.lstrip("\ufeff").lstrip()
low = head[:40].lower()
if low.startswith("(edif"):
return "edif"
if low.startswith("(kicad_sch"):
return "kicad_sch"
if low.startswith("(export"):
return "kicad_sexp"
if low.startswith("<?xml") or low.startswith("<export"):
return "kicad_xml"
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()[:2048]
fmt = detect_netlist_format(sample)
if fmt == "edif":
from backend.periscopex.parsers_edif import parse_edif_netlist
parts, nets = parse_edif_netlist(p, include_subdesigns=include_subdesigns)
elif fmt.startswith("kicad"):
from backend.periscopex.parsers_kicad import parse_kicad
parts, nets, _ = parse_kicad(p)
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), EDIF (.edn), "
"or KiCad netlist / .kicad_sch?"
)
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())
colnames = {n.lower() for n in (reader.fieldnames or []) if n}
has_dnp_col = bool(colnames & {"dnp", "dni", "fitted", "populate"})
has_variant_col = bool(colnames & {"variant"})
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 "").strip() or None
lcsc = row.get("LCSC", "") or None
datasheet_url = (row.get("Datasheet", "") or "").strip() or None
# Expand grouped references: "C1,C2,C5" -> ["C1", "C2", "C5"]
refs = [r.strip() for r in refs_raw.split(",") if r.strip()]
# KiCad exports often leave Manufacturer Part Number empty and put
# the orderable code in Value (or PNM). Without this, U* never
# enter ic_mpns and review reports "no datasheet PDF".
if not mpn:
mpn = (row.get("PNM", "") or "").strip() or None
if not mpn and any(re.match(r"^U\d", r, re.I) for r in refs):
mpn = (value or "").strip() or None
dnp_raw = (row.get("DNP") or row.get("DNI") or "").strip().lower()
fitted_raw = (row.get("Fitted") or row.get("Populate") or "").strip().lower()
variant = (row.get("Variant") or row.get("variant") or "").strip() or None
is_dnp = dnp_raw in {"1", "y", "yes", "true", "dnp", "dni", "x"}
if not is_dnp and fitted_raw in {"0", "n", "no", "false"}:
is_dnp = True
for ref in refs:
entry = {
"value": value,
"footprint": footprint,
"mpn": mpn,
"lcsc": lcsc,
"datasheet_url": datasheet_url,
}
if has_dnp_col:
entry["dnp"] = is_dnp
if has_variant_col:
entry["variant"] = variant
result[ref] = entry
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
+725
View File
@@ -0,0 +1,725 @@
"""KiCad netlist (XML / s-expression) and ``.kicad_sch`` parser.
Yields the same ``(parts, nets)`` shape as PADS/EDIF so graph build is format-agnostic.
``.kicad_sch`` uses embedded ``lib_symbols`` plus wires/labels. Hierarchical
``(sheet …)`` entries are followed from the root file (path-jailed under the
project directory).
"""
from __future__ import annotations
import math
import re
import xml.etree.ElementTree as ET
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Iterator
_MPN_FIELD_NAMES = {
"mpn", "manufacturer part number", "manufacturer_part_number",
"manf#", "part number", "partnumber", "p/n",
}
# ---------------------------------------------------------------------------
# S-expression
# ---------------------------------------------------------------------------
def _tokenize(text: str) -> Iterator[str]:
i, n = 0, len(text)
while i < n:
c = text[i]
if c.isspace():
i += 1
continue
if c == "(" or c == ")":
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 '"' + "".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(text: str) -> Any:
tokens = list(_tokenize(text))
it = iter(tokens)
def form() -> Any:
out: list[Any] = []
for tok in it:
if tok == "(":
out.append(form())
elif tok == ")":
return out
elif tok.startswith('"'):
out.append(tok[1:])
else:
out.append(tok)
return out
first = next(it, None)
if first != "(":
raise ValueError("KiCad file is not an s-expression")
return form()
def _tag(node: Any) -> str:
if isinstance(node, list) and node:
return str(node[0])
return ""
def _kids(node: Any, name: str) -> list[list]:
if not isinstance(node, list):
return []
return [x for x in node[1:] if isinstance(x, list) and x and x[0] == name]
def _kid(node: Any, name: str) -> list | None:
found = _kids(node, name)
return found[0] if found else None
def _val(node: Any, name: str) -> str:
k = _kid(node, name)
if not k or len(k) < 2:
return ""
return str(k[1])
def _unquote_attr(node: ET.Element, key: str) -> str:
return (node.get(key) or "").strip()
def _local(tag: str) -> str:
return tag.rsplit("}", 1)[-1]
# ---------------------------------------------------------------------------
# XML netlist (File → Export → Netlist)
# ---------------------------------------------------------------------------
def _iter_xml(root: ET.Element, name: str) -> Iterator[ET.Element]:
for el in root.iter():
if _local(el.tag) == name:
yield el
def parse_kicad_xml_netlist(path: str | Path) -> tuple[dict[str, str], dict[str, list[tuple[str, str]]], dict[str, dict]]:
tree = ET.parse(path)
root = tree.getroot()
parts: dict[str, str] = {}
fields: dict[str, dict] = {}
for comp in _iter_xml(root, "comp"):
ref = _unquote_attr(comp, "ref")
if not ref:
continue
value = ""
footprint = ""
mpn = None
lcsc = None
for child in list(comp):
loc = _local(child.tag)
if loc == "value":
value = (child.text or "").strip()
elif loc == "footprint":
footprint = (child.text or "").strip()
elif loc == "fields":
for field in child:
if _local(field.tag) != "field":
continue
fname = (field.get("name") or "").strip().lower()
fval = (field.text or "").strip()
if fname in _MPN_FIELD_NAMES and fval:
mpn = fval
elif fname == "lcsc" and fval:
lcsc = fval
elif loc == "property":
pname = (child.get("name") or "").strip().lower()
pval = (child.get("value") or child.text or "").strip()
if pname in _MPN_FIELD_NAMES and pval:
mpn = pval
elif pname == "lcsc" and pval:
lcsc = pval
parts[ref] = footprint
fields[ref] = {"value": value, "footprint": footprint, "mpn": mpn, "lcsc": lcsc}
nets: dict[str, list[tuple[str, str]]] = {}
for net in _iter_xml(root, "net"):
name = _unquote_attr(net, "name") or f"Net-{_unquote_attr(net, 'code')}"
pins: list[tuple[str, str]] = []
for node in net:
if _local(node.tag) != "node":
continue
ref = _unquote_attr(node, "ref")
pin = _unquote_attr(node, "pin")
if ref and pin:
pins.append((ref, pin))
if name:
nets[name] = pins
return parts, nets, fields
# ---------------------------------------------------------------------------
# S-expression netlist (kicad-cli sch export netlist)
# ---------------------------------------------------------------------------
def parse_kicad_sexp_netlist(tree: Any) -> tuple[dict[str, str], dict[str, list[tuple[str, str]]], dict[str, dict]]:
parts: dict[str, str] = {}
fields: dict[str, dict] = {}
comps = _kid(tree, "components") or []
for comp in comps[1:]:
if _tag(comp) != "comp":
continue
ref = _val(comp, "ref")
if not ref:
continue
value = _val(comp, "value")
footprint = _val(comp, "footprint")
mpn = None
lcsc = None
for field in _kids(_kid(comp, "fields") or [], "field"):
fname = ""
fval = ""
name_el = _kid(field, "name")
if name_el and len(name_el) >= 2:
fname = str(name_el[1]).lower()
strs = [str(x) for x in field[1:] if not isinstance(x, list)]
if strs:
fval = strs[-1]
if fname in _MPN_FIELD_NAMES and fval:
mpn = fval
elif fname == "lcsc" and fval:
lcsc = fval
parts[ref] = footprint
fields[ref] = {"value": value, "footprint": footprint, "mpn": mpn, "lcsc": lcsc}
nets: dict[str, list[tuple[str, str]]] = {}
nets_el = _kid(tree, "nets") or []
for net in nets_el[1:]:
if _tag(net) != "net":
continue
name = _val(net, "name") or f"Net-{_val(net, 'code')}"
pins: list[tuple[str, str]] = []
for node in _kids(net, "node"):
ref = _val(node, "ref")
pin = _val(node, "pin")
if ref and pin:
pins.append((ref, pin))
if name:
nets[name] = pins
return parts, nets, fields
# ---------------------------------------------------------------------------
# Single-sheet .kicad_sch (embedded lib_symbols + wires)
# ---------------------------------------------------------------------------
def _fnum(v: Any) -> float:
try:
return float(v)
except (TypeError, ValueError):
return 0.0
def _at(node: Any) -> tuple[float, float, float]:
k = _kid(node, "at")
if not k or len(k) < 3:
return 0.0, 0.0, 0.0
rot = _fnum(k[3]) if len(k) > 3 else 0.0
return _fnum(k[1]), _fnum(k[2]), rot
def _snap(x: float, y: float) -> tuple[int, int]:
return round(x * 1000), round(y * 1000)
def _rotate(px: float, py: float, deg: float) -> tuple[float, float]:
r = deg % 360.0
rad = math.radians(r)
c, s = math.cos(rad), math.sin(rad)
return px * c + py * s, -px * s + py * c
def _mirror_axes(sym: Any) -> tuple[bool, bool]:
"""KiCad ``(mirror x)`` / ``(mirror y)`` — flip symbol-local axes."""
m = _kid(sym, "mirror")
if not m:
return False, False
axes = {str(item) for item in m[1:]}
if not axes:
# Legacy bare ``(mirror)`` — treat as X flip (historical eeschema).
return True, False
return ("x" in axes), ("y" in axes)
def _point_on_segment(
p: tuple[int, int],
a: tuple[int, int],
b: tuple[int, int],
tol: int = 2,
) -> bool:
"""True if snapped point ``p`` lies on segment ``ab`` (inclusive)."""
ax, ay = a
bx, by = b
px, py = p
if px < min(ax, bx) - tol or px > max(ax, bx) + tol:
return False
if py < min(ay, by) - tol or py > max(ay, by) + tol:
return False
dx, dy = bx - ax, by - ay
len2 = dx * dx + dy * dy
if len2 == 0:
return abs(px - ax) <= tol and abs(py - ay) <= tol
# Distance from p to infinite line, then clamp to segment.
t = ((px - ax) * dx + (py - ay) * dy) / len2
if t < -0.01 or t > 1.01:
return False
qx = ax + t * dx
qy = ay + t * dy
return (px - qx) ** 2 + (py - qy) ** 2 <= tol * tol
def _lib_pins(sym: Any) -> dict[tuple[int, str], tuple[float, float]]:
"""(unit, pin_number) -> (x, y) in symbol space. unit 0 = common."""
out: dict[tuple[int, str], tuple[float, float]] = {}
def walk(node: Any, unit: int) -> None:
if not isinstance(node, list) or not node:
return
if node[0] == "symbol" and len(node) > 1 and isinstance(node[1], str):
# nested unit symbol Device:R_1_1 → unit 1
m = re.search(r"_(\d+)_(\d+)$", str(node[1]))
u = int(m.group(1)) if m else unit
for ch in node[1:]:
walk(ch, u)
return
if node[0] == "pin":
ax, ay, _ = _at(node)
num = _val(node, "number") or ""
if not num and len(node) > 1:
num = str(node[1])
if num:
out[(unit, num)] = (ax, ay)
out[(0, num)] = (ax, ay)
return
for ch in node[1:]:
if isinstance(ch, list):
walk(ch, unit)
walk(sym, 0)
return out
class _DSU:
def __init__(self) -> None:
self.p: dict[tuple[int, int], tuple[int, int]] = {}
def add(self, pt: tuple[int, int]) -> None:
self.p.setdefault(pt, pt)
def find(self, a: tuple[int, int]) -> tuple[int, int]:
self.add(a)
if self.p[a] != a:
self.p[a] = self.find(self.p[a])
return self.p[a]
def union(self, a: tuple[int, int], b: tuple[int, int]) -> None:
ra, rb = self.find(a), self.find(b)
if ra != rb:
self.p[rb] = ra
_KIND_RANK = {"unnamed": 0, "local": 1, "hier": 2, "global": 3}
@dataclass
class _SchSheet:
parts: dict[str, str]
nets: dict[str, list[tuple[str, str]]]
fields: dict[str, dict]
net_scope: dict[str, str]
sheetfiles: list[str] = field(default_factory=list)
def _sheetfiles(tree: Any) -> list[str]:
out: list[str] = []
for sheet in _kids(tree, "sheet"):
for p in _kids(sheet, "property"):
if len(p) >= 3 and str(p[1]) == "Sheetfile":
rel = str(p[2]).strip()
if rel:
out.append(rel)
return out
def _parse_kicad_sch_sheet(tree: Any) -> _SchSheet:
lib_pins: dict[str, dict[tuple[int, str], tuple[float, float]]] = {}
for sym in _kids(_kid(tree, "lib_symbols") or [], "symbol"):
lid = str(sym[1]) if len(sym) > 1 else ""
if lid:
lib_pins[lid] = _lib_pins(sym)
parts: dict[str, str] = {}
fields: dict[str, dict] = {}
pin_at: dict[tuple[str, str], tuple[int, int]] = {}
dsu = _DSU()
labels: dict[tuple[int, int], tuple[str, str]] = {}
power_pts: list[tuple[tuple[int, int], str]] = []
wire_segs: list[tuple[tuple[int, int], tuple[int, int]]] = []
def prop(sym: Any, key: str) -> str:
for p in _kids(sym, "property"):
if len(p) >= 3 and str(p[1]) == key:
return str(p[2])
return ""
def set_label(pt: tuple[int, int], name: str, kind: str) -> None:
if not name:
return
prev = labels.get(pt)
if prev is None or _KIND_RANK[kind] >= _KIND_RANK[prev[1]]:
labels[pt] = (name, kind)
def apply_sym_xy(px: float, py: float, rot: float, mx: bool, my: bool) -> tuple[float, float]:
rx, ry = _rotate(px, py, rot)
if mx:
rx = -rx
if my:
ry = -ry
return rx, ry
for sym in _kids(tree, "symbol"):
lib_id = _val(sym, "lib_id")
ix, iy, rot = _at(sym)
unit = int(_fnum(_val(sym, "unit") or "1") or 1)
mx, my = _mirror_axes(sym)
ref = prop(sym, "Reference")
if ref.startswith("#"):
# power flag / graphic
val = prop(sym, "Value") or lib_id.rsplit(":", 1)[-1]
lp = lib_pins.get(lib_id, {})
xy = lp.get((unit, "1")) or lp.get((0, "1")) or (0.0, 0.0)
px, py = apply_sym_xy(xy[0], xy[1], rot, mx, my)
pt = _snap(ix + px, iy + py)
dsu.add(pt)
if val:
power_pts.append((pt, val))
continue
if not ref:
continue
value = prop(sym, "Value")
footprint = prop(sym, "Footprint")
mpn = None
lcsc = None
for p in _kids(sym, "property"):
if len(p) < 3:
continue
n = str(p[1]).strip().lower()
v = str(p[2]).strip()
if n in _MPN_FIELD_NAMES and v:
mpn = v
elif n == "lcsc" and v:
lcsc = v
parts[ref] = footprint
fields[ref] = {
"value": value, "footprint": footprint, "mpn": mpn, "lcsc": lcsc,
"cad_uuid": _val(sym, "uuid"),
}
lp = lib_pins.get(lib_id, {})
for pin_el in _kids(sym, "pin"):
num = str(pin_el[1]) if len(pin_el) > 1 else ""
if not num:
continue
xy = lp.get((unit, num)) or lp.get((0, num)) or (0.0, 0.0)
px, py = apply_sym_xy(xy[0], xy[1], rot, mx, my)
pt = _snap(ix + px, iy + py)
pin_at[(ref, num)] = pt
dsu.add(pt)
def collect_pts(node: Any) -> None:
if not isinstance(node, list) or not node:
return
tag = node[0]
if tag == "wire":
pts = _kid(node, "pts")
coords: list[tuple[int, int]] = []
if pts:
for xy in _kids(pts, "xy"):
if len(xy) >= 3:
pt = _snap(_fnum(xy[1]), _fnum(xy[2]))
dsu.add(pt)
coords.append(pt)
for a, b in zip(coords, coords[1:]):
dsu.union(a, b)
wire_segs.append((a, b))
return
if tag == "label":
name = str(node[1]) if len(node) > 1 else ""
x, y, _ = _at(node)
pt = _snap(x, y)
dsu.add(pt)
set_label(pt, name, "local")
return
if tag == "global_label":
name = str(node[1]) if len(node) > 1 else ""
x, y, _ = _at(node)
pt = _snap(x, y)
dsu.add(pt)
set_label(pt, name, "global")
return
if tag == "hierarchical_label":
name = str(node[1]) if len(node) > 1 else ""
x, y, _ = _at(node)
pt = _snap(x, y)
dsu.add(pt)
set_label(pt, name, "hier")
return
if tag == "sheet":
for pin in _kids(node, "pin"):
name = str(pin[1]) if len(pin) > 1 else ""
x, y, _ = _at(pin)
pt = _snap(x, y)
dsu.add(pt)
set_label(pt, name, "hier")
return
if tag == "junction":
x, y, _ = _at(node)
dsu.add(_snap(x, y))
return
for ch in node[1:]:
if isinstance(ch, list):
collect_pts(ch)
collect_pts(tree)
for pt in labels:
dsu.add(pt)
for pt, _name in power_pts:
dsu.add(pt)
# Pins / labels / power on the middle of a wire share that net.
attach_pts = list(pin_at.values()) + list(labels.keys()) + [pt for pt, _ in power_pts]
for pt in attach_pts:
for a, b in wire_segs:
if _point_on_segment(pt, a, b):
dsu.union(pt, a)
dsu.union(pt, b)
# KiCad semantics: same-name global labels and power symbols are one net
# even when not geometrically connected. Same-name local labels merge
# within a single sheet.
by_name: dict[tuple[str, str], list[tuple[int, int]]] = {}
for pt, (name, kind) in labels.items():
if kind in ("global", "local", "hier"):
by_name.setdefault((kind, name), []).append(pt)
for pt, name in power_pts:
by_name.setdefault(("global", name), []).append(pt)
for pts in by_name.values():
if len(pts) < 2:
continue
head = pts[0]
for p in pts[1:]:
dsu.union(head, p)
root_name: dict[tuple[int, int], str] = {}
root_kind: dict[tuple[int, int], str] = {}
for pt, (name, kind) in labels.items():
r = dsu.find(pt)
prev = root_kind.get(r, "unnamed")
if _KIND_RANK[kind] >= _KIND_RANK[prev]:
root_name[r] = name
root_kind[r] = kind
for pt, name in power_pts:
r = dsu.find(pt)
prev = root_kind.get(r, "unnamed")
if _KIND_RANK["global"] >= _KIND_RANK[prev]:
root_name[r] = name
root_kind[r] = "global"
grouped: dict[tuple[int, int], list[tuple[str, str]]] = {}
for (ref, pin), pt in pin_at.items():
grouped.setdefault(dsu.find(pt), []).append((ref, pin))
nets: dict[str, list[tuple[str, str]]] = {}
net_scope: dict[str, str] = {}
used_names: set[str] = set()
for root, pins in grouped.items():
name = root_name.get(root)
kind = root_kind.get(root, "unnamed")
if not name:
ref0, pin0 = pins[0]
name = f"Net-({ref0}-Pad{pin0})"
kind = "unnamed"
while name in used_names:
name = name + "_"
used_names.add(name)
nets[name] = pins
net_scope[name] = kind
return _SchSheet(
parts=parts,
nets=nets,
fields=fields,
net_scope=net_scope,
sheetfiles=_sheetfiles(tree),
)
def parse_kicad_sch(tree: Any) -> tuple[dict[str, str], dict[str, list[tuple[str, str]]], dict[str, dict]]:
sheet = _parse_kicad_sch_sheet(tree)
return sheet.parts, sheet.nets, sheet.fields
def _uniq_pins(pins: list[tuple[str, str]]) -> list[tuple[str, str]]:
seen: set[tuple[str, str]] = set()
out: list[tuple[str, str]] = []
for p in pins:
if p not in seen:
seen.add(p)
out.append(p)
return out
def _safe_sheetfile(parent: Path, rel: str, project_root: Path) -> Path:
rel_norm = rel.replace("\\", "/").strip()
if not rel_norm or rel_norm.startswith("/") or ".." in Path(rel_norm).parts:
raise ValueError(f"Sheetfile path rejected: {rel}")
child = (parent.parent / rel_norm).resolve()
root = project_root.resolve()
try:
child.relative_to(root)
except ValueError:
raise ValueError(f"Sheetfile path rejected: {rel}") from None
return child
def parse_kicad_sch_project(
root_path: str | Path,
) -> tuple[dict[str, str], dict[str, list[tuple[str, str]]], dict[str, dict]]:
root = Path(root_path).resolve()
project_root = root.parent
seen: set[Path] = set()
loaded: list[tuple[Path, _SchSheet]] = []
def visit(path: Path) -> None:
path = path.resolve()
if path in seen:
raise ValueError(f"Cyclic sheet include: {path.name}")
if not path.is_file():
raise ValueError(
f"Missing sheet file: {path.name}. Drop the whole KiCad "
"project folder, not a single sheet."
)
seen.add(path)
text = path.read_text(encoding="utf-8", errors="replace")
tree = _parse_sexp(text)
if _tag(tree) != "kicad_sch":
raise ValueError(f"Expected kicad_sch in {path.name}, got {_tag(tree)!r}")
sheet = _parse_kicad_sch_sheet(tree)
loaded.append((path, sheet))
for rel in sheet.sheetfiles:
child = _safe_sheetfile(path, rel, project_root)
visit(child)
visit(root)
parts: dict[str, str] = {}
fields: dict[str, dict] = {}
global_nets: dict[str, list[tuple[str, str]]] = {}
hier_nets: dict[str, list[tuple[str, str]]] = {}
local_nets: dict[str, list[tuple[str, str]]] = {}
multi = len(loaded) > 1
for path, sheet in loaded:
for ref, fp in sheet.parts.items():
if ref in parts:
raise ValueError(f"Duplicate reference {ref} in {path.name}")
parts[ref] = fp
fields[ref] = {
**sheet.fields.get(ref, {}),
"cad_sheet": path.name,
}
for name, pins in sheet.nets.items():
scope = sheet.net_scope.get(name, "unnamed")
if scope == "global":
global_nets[name] = _uniq_pins(global_nets.get(name, []) + pins)
elif scope == "hier":
hier_nets[name] = _uniq_pins(hier_nets.get(name, []) + pins)
else:
out_name = f"{path.stem}/{name}" if multi else name
local_nets[out_name] = _uniq_pins(local_nets.get(out_name, []) + pins)
nets: dict[str, list[tuple[str, str]]] = {}
for name, pins in global_nets.items():
nets[name] = pins
for name, pins in hier_nets.items():
nets[name] = _uniq_pins(nets.get(name, []) + pins)
for name, pins in local_nets.items():
out = name
while out in nets:
out = out + "_"
nets[out] = pins
return parts, nets, fields
# ---------------------------------------------------------------------------
# Public
# ---------------------------------------------------------------------------
_fields_cache: dict[str, dict[str, dict]] = {}
def parse_kicad(
path: str | Path,
) -> tuple[dict[str, str], dict[str, list[tuple[str, str]]], dict[str, dict]]:
p = Path(path)
raw = p.read_bytes()
head = raw[:256].decode("utf-8", errors="replace").lstrip("\ufeff").lstrip()
if head.startswith("<") or head.startswith("<?xml"):
parts, nets, fields = parse_kicad_xml_netlist(p)
else:
text = p.read_text(encoding="utf-8", errors="replace")
tree = _parse_sexp(text)
tag = _tag(tree)
if tag == "kicad_sch":
parts, nets, fields = parse_kicad_sch_project(p)
elif tag == "export":
parts, nets, fields = parse_kicad_sexp_netlist(tree)
else:
raise ValueError(f"Unsupported KiCad s-expression root {tag!r}")
if not parts:
raise ValueError("No components found in KiCad file")
if not nets:
raise ValueError(
"No nets found. For a multi-sheet schematic, export a netlist "
"(File → Export → Netlist) instead of uploading .kicad_sch."
)
_fields_cache[str(p.resolve())] = fields
return parts, nets, fields
def kicad_part_fields(path: str | Path) -> dict[str, dict]:
key = str(Path(path).resolve())
if key not in _fields_cache:
parse_kicad(path)
return _fields_cache.get(key, {})
+311
View File
@@ -0,0 +1,311 @@
"""KiCad `.kicad_pcb` ingest — footprints, pads, nets, segments, vias.
No SI/DRC. Schema validation stays complete without this file.
"""
from __future__ import annotations
from pathlib import Path
from backend.periscopex.models import (
LayoutDielectric,
LayoutFootprint,
LayoutGraph,
LayoutPad,
LayoutSegment,
LayoutStackup,
LayoutVia,
LayoutZone,
)
from backend.periscopex.parsers_kicad import (
_at,
_fnum,
_kid,
_kids,
_parse_sexp,
_rotate,
_tag,
_val,
)
def _xy(node: object, name: str) -> tuple[float, float]:
k = _kid(node, name)
if not k or len(k) < 3:
return 0.0, 0.0
return _fnum(k[1]), _fnum(k[2])
def _prop(node: object, key: str) -> str:
for p in _kids(node, "property"):
if len(p) >= 3 and str(p[1]) == key:
return str(p[2])
return ""
def _pad_net(pad: object) -> str:
n = _kid(pad, "net")
if not n or len(n) < 2:
return ""
# KiCad 9/10 often stores ``(net "GND")`` without a numeric code.
if len(n) == 2 and not isinstance(n[1], list):
return str(n[1])
# Legacy ``(net 3 "GND")``.
if len(n) >= 3:
return str(n[2])
return ""
def _normalize_pcb_net_name(name: str) -> str:
"""Strip KiCad root-sheet ``/`` prefixes; keep empty / unconnected as-is."""
n = (name or "").strip()
if not n:
return ""
while n.startswith("/"):
n = n[1:]
return n
def nets_from_pcb(layout: LayoutGraph) -> dict[str, list[tuple[str, str]]]:
"""Pad connectivity from a parsed board — authoritative when sch geometry fails."""
nets: dict[str, list[tuple[str, str]]] = {}
seen: set[tuple[str, str, str]] = set()
for ref, fp in layout.footprints.items():
for pad in fp.pads:
raw = pad.net or ""
if not raw:
continue
name = _normalize_pcb_net_name(raw)
if not name:
continue
key = (name, ref, pad.number)
if key in seen:
continue
seen.add(key)
nets.setdefault(name, []).append((ref, pad.number))
return nets
def _net_name(node: object, nets: dict[str, int]) -> str:
n = _kid(node, "net")
if not n or len(n) < 2:
named = _val(node, "net_name")
return named
# ``(net "GND")`` or ``(net 1)`` or ``(net 1 "GND")``
if len(n) == 2 and not isinstance(n[1], list):
token = n[1]
if isinstance(token, str) and not str(token).replace(".", "", 1).isdigit():
return str(token)
try:
code = int(_fnum(token))
except (TypeError, ValueError):
return str(token)
return next((name for name, c in nets.items() if c == code), str(code))
if len(n) >= 3:
return str(n[2])
try:
code = int(_fnum(n[1]))
except (TypeError, ValueError):
return ""
return next((name for name, c in nets.items() if c == code), str(code))
def _layer_type(node: object) -> str:
return str(_val(node, "type") or "").lower()
def _parse_stackup(tree: object) -> LayoutStackup | None:
setup = _kid(tree, "setup")
if not setup:
return None
stack = _kid(setup, "stackup")
if not stack:
return None
copper: list[str] = []
dielectrics: list[LayoutDielectric] = []
thicknesses: list[float] = []
for layer in _kids(stack, "layer"):
name = str(layer[1]) if len(layer) > 1 and not isinstance(layer[1], list) else ""
kind = _layer_type(layer)
thick = _kid(layer, "thickness")
height = _fnum(thick[1]) if thick and len(thick) > 1 else None
if kind == "copper" or name.endswith(".Cu"):
if name:
copper.append(name)
if height is not None and height > 0:
thicknesses.append(height)
continue
if kind in {"core", "prepreg", "dielectric"} or name.lower().startswith("dielectric"):
er_el = _kid(layer, "epsilon_r")
if er_el is None:
er_el = _kid(layer, "epsilonr")
er = _fnum(er_el[1]) if er_el and len(er_el) > 1 else None
if er is None or height is None or er <= 0 or height <= 0:
continue
dielectrics.append(LayoutDielectric(
name=name or f"dielectric_{len(dielectrics)}",
er=er,
height_mm=height,
))
if len(copper) < 2 or len(dielectrics) != len(copper) - 1:
return None
t = thicknesses[0] if thicknesses else None
return LayoutStackup(
copper_layers=copper,
dielectrics=dielectrics,
copper_thickness_mm=t,
)
def _pts_xy(node: object) -> list[tuple[float, float]]:
pts_el = _kid(node, "pts")
if not pts_el:
return []
out: list[tuple[float, float]] = []
for xy in pts_el[1:]:
if isinstance(xy, list) and xy and xy[0] == "xy" and len(xy) >= 3:
out.append((_fnum(xy[1]), _fnum(xy[2])))
return out
def _parse_zone(node: object, nets: dict[str, int]) -> list[LayoutZone]:
net = str(_val(node, "net_name") or "") or _net_name(node, nets)
zones: list[LayoutZone] = []
for poly in _kids(node, "filled_polygon"):
layer = _val(poly, "layer")
pts = _pts_xy(poly)
if layer and len(pts) >= 3:
zones.append(LayoutZone(net=net, layer=layer, outlines=[pts]))
return zones
def _is_crtyd(layer: str) -> bool:
return str(layer).endswith("CrtYd")
def _abs(fx: float, fy: float, frot: float, lx: float, ly: float) -> tuple[float, float]:
rx, ry = _rotate(lx, ly, frot)
return fx + rx, fy + ry
def _courtyard_pts(node: object, fx: float, fy: float, frot: float) -> list[tuple[float, float]]:
"""Courtyard vertices from the PCB file. Empty if KiCad has no CrtYd."""
pts: list[tuple[float, float]] = []
for poly in _kids(node, "fp_poly"):
if not _is_crtyd(_val(poly, "layer")):
continue
pts_el = _kid(poly, "pts")
if not pts_el:
continue
for xy in pts_el[1:]:
if isinstance(xy, list) and xy and xy[0] == "xy" and len(xy) >= 3:
pts.append(_abs(fx, fy, frot, _fnum(xy[1]), _fnum(xy[2])))
if pts:
return pts
for rect in _kids(node, "fp_rect"):
if not _is_crtyd(_val(rect, "layer")):
continue
sx, sy = _xy(rect, "start")
ex, ey = _xy(rect, "end")
return [
_abs(fx, fy, frot, sx, sy),
_abs(fx, fy, frot, ex, sy),
_abs(fx, fy, frot, ex, ey),
_abs(fx, fy, frot, sx, ey),
]
for line in _kids(node, "fp_line"):
if not _is_crtyd(_val(line, "layer")):
continue
sx, sy = _xy(line, "start")
ex, ey = _xy(line, "end")
a = _abs(fx, fy, frot, sx, sy)
b = _abs(fx, fy, frot, ex, ey)
if not pts or pts[-1] != a:
pts.append(a)
if pts[-1] != b:
pts.append(b)
return pts
def parse_kicad_pcb(path: str | Path) -> LayoutGraph:
p = Path(path)
tree = _parse_sexp(p.read_text(encoding="utf-8", errors="replace"))
if _tag(tree) != "kicad_pcb":
raise ValueError(f"Expected kicad_pcb, got {_tag(tree)!r}")
nets: dict[str, int] = {}
footprints: dict[str, LayoutFootprint] = {}
segments: list[LayoutSegment] = []
vias: list[LayoutVia] = []
zones: list[LayoutZone] = []
for node in tree[1:]:
if not isinstance(node, list) or not node:
continue
tag = _tag(node)
if tag == "net" and len(node) >= 3 and not any(isinstance(x, list) and x and x[0] == "node" for x in node[1:]):
try:
code = int(_fnum(node[1]))
except (TypeError, ValueError):
continue
name = str(node[2])
if name:
nets[name] = code
continue
if tag in {"footprint", "module"}:
fp_name = str(node[1]) if len(node) > 1 and not isinstance(node[1], list) else ""
fx, fy, frot = _at(node)
layer = _val(node, "layer")
ref = _prop(node, "Reference")
if not ref or ref.startswith("#"):
continue
pads: list[LayoutPad] = []
for pad in _kids(node, "pad"):
num = str(pad[1]) if len(pad) > 1 else ""
if not num:
continue
px, py, _ = _at(pad)
rx, ry = _rotate(px, py, frot)
pads.append(LayoutPad(
number=num,
x=fx + rx,
y=fy + ry,
net=_pad_net(pad),
))
footprints[ref] = LayoutFootprint(
reference=ref,
footprint=fp_name,
x=fx,
y=fy,
layer=layer,
pads=pads,
courtyard=_courtyard_pts(node, fx, fy, frot),
)
continue
if tag == "segment":
segments.append(LayoutSegment(
start=_xy(node, "start"),
end=_xy(node, "end"),
width=_fnum(_val(node, "width") or 0),
layer=_val(node, "layer"),
net=_net_name(node, nets),
))
continue
if tag == "via":
drill_el = _kid(node, "drill")
drill = _fnum(drill_el[1]) if drill_el and len(drill_el) > 1 else None
vx, vy, _ = _at(node)
vias.append(LayoutVia(x=vx, y=vy, net=_net_name(node, nets), drill=drill))
continue
if tag == "zone":
zones.extend(_parse_zone(node, nets))
continue
return LayoutGraph(
nets=nets,
footprints=footprints,
segments=segments,
vias=vias,
stackup=_parse_stackup(tree),
zones=zones,
)
+524
View File
@@ -0,0 +1,524 @@
"""Deterministic supply decoupling and I2C/reset pull-up checks.
These only fire when the graph already shows a pintable supply pin, an I2C
net/pin name, or a reset pin — they do not guess capacitor values, mux
alt-functions, or datasheet µF minima.
"""
from __future__ import annotations
import re
from backend.periscopex.models import (
CapacitorSpecs,
Component,
ComponentConstraints,
ComponentType,
DesignGraph,
Finding,
NetType,
ResistorSpecs,
)
from backend.periscopex.validate import _match_constraints
from backend.periscopex.led_current_check import _parse_resistance
from backend.periscopex.resolve_passives import _parse_spice_value
_SUPPLY_PIN_RE = re.compile(
r"(?:^|[_/])(VDD|VCC|VDDA|VDDD|VDDIO|DVDD|AVDD|IOVDD|VDD33|VDD18|"
r"VIN|VBAT|VBUS|VCORE)(?:$|[_/\d])",
re.IGNORECASE,
)
_RAIL_PIN_RE = re.compile(r"^(?:\+?\d+V\d*)$", re.IGNORECASE)
_NOT_SUPPLY_RE = re.compile(
r"\b(VSS|GND|VEE|VOUT|VREF|SW|LX|FB|BOOT|NC|VPP)\b",
re.IGNORECASE,
)
_I2C_RE = re.compile(r"(?:^|[^A-Za-z0-9])(SDA|SCL)(\d+)?(?:$|[^A-Za-z0-9])", re.IGNORECASE)
_SPI_NAME_RE = re.compile(r"(?i)\b(MISO|MOSI|SCLK|SCK)\b")
_RESET_RE = re.compile(
r"\b(N?RST(?:N|B)?|NRST|RESET(?:_?N|_?B)?|NRESET|CHIP_PU)\b",
re.IGNORECASE,
)
_NC_NET_RE = re.compile(
r"^(?:n/?c|n\.c\.|nc|unconnected|no[_-]?connect|not[_-]?connected)$",
re.IGNORECASE,
)
_OUT_PIN_RE = re.compile(
r"(?:^|[_/])(VOUT|V_OUT|VO|VREG|SWOUT)(?:$|[_/\d])",
re.IGNORECASE,
)
_ACTIVE_LOW_RESET_RE = re.compile(
r"(?:N/?RST|NRST|NRESET|RESET[_-]?N|RSTN)\b",
re.IGNORECASE,
)
# NXP UM10204-style Rp window, widened so 2.2k10k never false-positives.
_RP_MIN_OHM = 1_000.0
_RP_MAX_OHM = 22_000.0
_VDD_MIN_FARADS = 50e-9
_VOUT_MIN_FARADS = 0.47e-6
def check_supply_decoupling(
graph: DesignGraph,
constraints_map: dict[str, ComponentConstraints],
) -> list[Finding]:
"""WARNING when an IC supply/VOUT net has no capacitor to ground, or
only farads well below a typical Cin/Cout when every cap is valued."""
findings: list[Finding] = []
seen_nets: set[str] = set()
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)
for pin_num, net_name in sorted(comp.pins.items(), key=lambda x: str(x[0])):
if net_name in seen_nets:
continue
if _is_nc_net(net_name):
continue
role = None
if _is_ic_supply_pin(graph, cons, pin_num, net_name):
role = "supply"
elif _is_regulator_output_pin(cons, pin_num):
role = "output"
if role is None:
continue
seen_nets.add(net_name)
pin_label = _pin_label(cons, pin_num, net_name)
if not _capacitor_to_ground(graph, net_name):
findings.append(Finding(
designator=ref,
mpn=comp.mpn or "",
aspect="decoupling",
source="supply_decoupling_check",
source_page=None,
status="WARNING",
finding=(
f"{ref} supply net '{net_name}' ({pin_label}) has no "
f"capacitor to ground."
if role == "supply"
else (
f"{ref} regulator output '{net_name}' ({pin_label}) "
f"has no Cout capacitor to ground."
)
),
why=(
f"Pin {pin_label} sits on '{net_name}' and that net has no "
f"capacitor whose other end is ground. Local decoupling "
f"may be missing (or only present on a different island "
f"behind a ferrite)."
),
recommendation=(
f"Add a decoupling capacitor from '{net_name}' to ground "
f"near {ref}."
),
reference="netlist topology",
net=net_name,
pins=[f"{ref}.{pin_num}"],
rule_id="PE-DEC-001",
))
continue
min_f = _VOUT_MIN_FARADS if role == "output" else _VDD_MIN_FARADS
max_c = _max_known_cap_farads(graph, net_name)
if max_c is not None and max_c < min_f:
findings.append(Finding(
designator=ref,
mpn=comp.mpn or "",
aspect="decoupling",
source="supply_decoupling_check",
source_page=None,
status="WARNING",
finding=(
f"{ref} net '{net_name}' ({pin_label}) only has "
f"{max_c * 1e6:.3g} µF to ground; typical "
f"{'Cout' if role == 'output' else 'decoupling'} is larger."
),
why=(
"Cap values are known on this net and the largest is "
"below a wide typical minimum. This is not a datasheet "
"µF requirement — treat as a sizing hint."
),
recommendation=(
f"Add bulk capacitance on '{net_name}' (often ≥1 µF on "
f"LDO VOUT, ≥100 nF on MCU VDD) if the datasheet agrees."
),
reference="netlist topology",
net=net_name,
pins=[f"{ref}.{pin_num}"],
rule_id="PE-DEC-002",
))
return findings
def check_i2c_pullups(
graph: DesignGraph,
constraints_map: dict[str, ComponentConstraints],
) -> list[Finding]:
"""WARNING when an SDA/SCL net has no resistor to a power rail."""
findings: list[Finding] = []
seen_nets: set[str] = set()
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)
for pin_num, net_name in sorted(comp.pins.items(), key=lambda x: str(x[0])):
if net_name in seen_nets:
continue
if _is_nc_net(net_name):
continue
if not _is_i2c_pin(graph, cons, pin_num, net_name):
continue
seen_nets.add(net_name)
net = graph.nets.get(net_name)
if net and net.net_type in (NetType.POWER, NetType.GROUND):
continue
if _resistor_to_power(graph, net_name):
ohms = _parallel_pullup_ohms(graph, net_name)
if ohms is not None and (
ohms < _RP_MIN_OHM or ohms > _RP_MAX_OHM
):
pin_label = _pin_label(cons, pin_num, net_name)
findings.append(Finding(
designator=ref,
mpn=comp.mpn or "",
aspect="i2c_pullup",
source="i2c_pullup_check",
source_page=None,
status="WARNING",
finding=(
f"I2C net '{net_name}' ({ref} {pin_label}) pull-up "
f"is {ohms:.3g} Ω (wide NXP-style band "
f"{_RP_MIN_OHM:.0f}{_RP_MAX_OHM:.0f} Ω)."
),
why=(
"UM10204 Rp depends on Vdd, Iol and bus capacitance. "
"This bound is wide on purpose; 2.210 kΩ at 3.3 V "
"is typical. Unknown resistor values are not sized."
),
recommendation=(
f"Use a pull-up on '{net_name}' inside "
f"{_RP_MIN_OHM:.0f}{_RP_MAX_OHM:.0f} Ω unless the "
f"bus capacitance/Iol calculation says otherwise."
),
reference="NXP UM10204 (wide bound)",
net=net_name,
pins=[f"{ref}.{pin_num}"],
rule_id="PE-I2C-002",
))
continue
pin_label = _pin_label(cons, pin_num, net_name)
findings.append(Finding(
designator=ref,
mpn=comp.mpn or "",
aspect="i2c_pullup",
source="i2c_pullup_check",
source_page=None,
status="WARNING",
finding=(
f"I2C net '{net_name}' ({ref} {pin_label}) has no pull-up "
f"resistor to a power rail."
),
why=(
f"SDA/SCL is open-drain. Without a resistor from "
f"'{net_name}' to a supply, the bus cannot idle high."
),
recommendation=(
f"Add a pull-up (typically 2.210 kΩ) from '{net_name}' "
f"to the I2C I/O rail."
),
reference="netlist topology",
net=net_name,
pins=[f"{ref}.{pin_num}"],
rule_id="PE-I2C-001",
))
return findings
def check_reset_pullups(
graph: DesignGraph,
constraints_map: dict[str, ComponentConstraints],
) -> list[Finding]:
"""WARNING when a reset pin's net is only this IC and has no pull-up."""
findings: list[Finding] = []
seen_nets: set[str] = set()
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)
for pin_num, net_name in sorted(comp.pins.items(), key=lambda x: str(x[0])):
if net_name in seen_nets:
continue
if _is_nc_net(net_name):
continue
if not _is_reset_pin(graph, cons, pin_num, net_name):
continue
seen_nets.add(net_name)
net = graph.nets.get(net_name)
if net and net.net_type in (NetType.POWER, NetType.GROUND):
continue
if _other_ic_on_net(graph, net_name, ref):
continue
pin_label = _pin_label(cons, pin_num, net_name)
if _is_active_low_reset(cons, pin_num, net_name) and _resistor_to_ground(
graph, net_name
):
findings.append(Finding(
designator=ref,
mpn=comp.mpn or "",
aspect="reset_pullup",
source="reset_pullup_check",
source_page=None,
status="WARNING",
finding=(
f"{ref} active-low reset '{net_name}' ({pin_label}) "
f"has a pull-down to ground."
),
why=(
"An active-low NRST/RESET_N pin held down by a resistor "
"will sit in reset unless a stronger pull-up wins. "
"Datasheets that omit an internal pull-up expect a pull-up, "
"not a pull-down."
),
recommendation=(
f"Remove the pull-down on '{net_name}' or replace it "
f"with a pull-up to the I/O rail."
),
reference="netlist topology",
net=net_name,
pins=[f"{ref}.{pin_num}"],
rule_id="PE-RST-002",
))
if _resistor_to_power(graph, net_name):
continue
findings.append(Finding(
designator=ref,
mpn=comp.mpn or "",
aspect="reset_pullup",
source="reset_pullup_check",
source_page=None,
status="WARNING",
finding=(
f"{ref} reset pin {pin_label} on '{net_name}' has no "
f"pull-up and no other IC driving the net."
),
why=(
f"The net only lands on {ref} (plus passives). Without a "
f"resistor to a supply, an active-low reset input can float."
),
recommendation=(
f"Add a pull-up to the I/O rail, or drive '{net_name}' "
f"from a reset supervisor / GPIO."
),
reference="netlist topology",
net=net_name,
pins=[f"{ref}.{pin_num}"],
rule_id="PE-RST-001",
))
return findings
def _pin_label(cons: ComponentConstraints | None, pin_num: str, net_name: str) -> str:
if cons:
pin = cons.pin_by_number(pin_num)
if pin and pin.name:
return f"{pin_num} ({pin.name})"
return str(pin_num)
def _is_nc_net(name: str) -> bool:
return bool(_NC_NET_RE.match((name or "").strip()))
def _pin_name_tokens(cons: ComponentConstraints | None, pin_num: str) -> list[str]:
"""Slash-separated pin *name* tokens only — not the mux alt-function table."""
if not cons:
return []
pin = cons.pin_by_number(pin_num)
if not pin or not pin.name:
return []
return [t.strip() for t in re.split(r"[/,]", pin.name) if t.strip()]
def _looks_like_supply(text: str) -> bool:
t = (text or "").strip()
if not t:
return False
if _NOT_SUPPLY_RE.search(t) and not _SUPPLY_PIN_RE.search(t):
return False
return bool(_SUPPLY_PIN_RE.search(t) or _RAIL_PIN_RE.match(t))
def _is_ic_supply_pin(
graph: DesignGraph,
cons: ComponentConstraints | None,
pin_num: str,
net_name: str,
) -> bool:
tokens = _pin_name_tokens(cons, pin_num)
if tokens:
return any(_looks_like_supply(t) for t in tokens)
# No pintable row: fall back to net name / POWER type.
if _looks_like_supply(net_name or ""):
return True
net = graph.nets.get(net_name)
return bool(net and net.net_type == NetType.POWER)
def _is_i2c_pin(
graph: DesignGraph,
cons: ComponentConstraints | None,
pin_num: str,
net_name: str,
) -> bool:
net = net_name or ""
if re.match(r"(?i)SPI([_-]|$)", net) or re.search(
r"(?i)\bSPI[_-]?(CLK|SCK|MOSI|MISO|CS|SS)\b", net,
):
return False
tokens = _pin_name_tokens(cons, pin_num)
if any(_SPI_NAME_RE.search(t) for t in tokens):
return False
if _I2C_RE.search(net):
return True
return any(_I2C_RE.search(t) for t in tokens)
def _is_reset_pin(
graph: DesignGraph,
cons: ComponentConstraints | None,
pin_num: str,
net_name: str,
) -> bool:
if _RESET_RE.search(net_name or ""):
return True
return any(_RESET_RE.search(t) for t in _pin_name_tokens(cons, pin_num))
def _is_ground_net(graph: DesignGraph, name: str) -> bool:
net = graph.nets.get(name)
if net and net.net_type == NetType.GROUND:
return True
u = name.upper().replace("-", "_")
return u in ("GND", "VSS", "AGND", "DGND", "PGND", "GNDA", "GNDD") or (
u.startswith("GND") or u.endswith("_GND") or u.endswith("_VSS")
)
def _is_power_net(graph: DesignGraph, name: str) -> bool:
net = graph.nets.get(name)
if net and net.net_type == NetType.POWER:
return True
return bool(re.match(r"^\d+V\d*", (name or "").upper()))
def _capacitor_to_ground(graph: DesignGraph, power_net: str) -> bool:
for ref in graph.capacitors_on_net(power_net):
cap = graph.components[ref]
others = {n for n in cap.pins.values() if n != power_net}
if any(_is_ground_net(graph, n) for n in others):
return True
return False
def _resistor_to_power(graph: DesignGraph, net_name: str) -> bool:
for ref in graph.components_on_net(net_name):
comp = graph.components[ref]
if comp.component_type != ComponentType.RESISTOR:
continue
others = {n for n in comp.pins.values() if n != net_name}
if any(_is_power_net(graph, n) for n in others):
return True
return False
def _resistor_to_ground(graph: DesignGraph, net_name: str) -> bool:
for ref in graph.components_on_net(net_name):
comp = graph.components[ref]
if comp.component_type != ComponentType.RESISTOR:
continue
others = {n for n in comp.pins.values() if n != net_name}
if any(_is_ground_net(graph, n) for n in others):
return True
return False
def _resistor_ohms(comp: Component) -> float | None:
specs = comp.specs
if isinstance(specs, ResistorSpecs) and specs.value_ohms > 0:
return float(specs.value_ohms)
return _parse_resistance(comp.value)
def _parallel_pullup_ohms(graph: DesignGraph, net_name: str) -> float | None:
acc = 0.0
known = 0
for ref in graph.components_on_net(net_name):
comp = graph.components[ref]
if comp.component_type != ComponentType.RESISTOR:
continue
others = {n for n in comp.pins.values() if n != net_name}
if not any(_is_power_net(graph, n) for n in others):
continue
ohms = _resistor_ohms(comp)
if ohms is None or ohms <= 0:
return None
acc += 1.0 / ohms
known += 1
if not known or acc <= 0:
return None
return 1.0 / acc
def _cap_farads(comp: Component) -> float | None:
specs = comp.specs
if isinstance(specs, CapacitorSpecs) and specs.value_farads > 0:
return float(specs.value_farads)
raw = (comp.value or "").strip()
if not raw:
return None
try:
v = _parse_spice_value(raw)
except ValueError:
return None
return v if v > 0 else None
def _max_known_cap_farads(graph: DesignGraph, power_net: str) -> float | None:
known: list[float] = []
any_unknown = False
for ref in graph.capacitors_on_net(power_net):
cap = graph.components[ref]
others = {n for n in cap.pins.values() if n != power_net}
if not any(_is_ground_net(graph, n) for n in others):
continue
farads = _cap_farads(cap)
if farads is None:
any_unknown = True
continue
known.append(farads)
if any_unknown or not known:
return None
return max(known)
def _is_regulator_output_pin(
cons: ComponentConstraints | None, pin_num: str,
) -> bool:
return any(_OUT_PIN_RE.search(t) for t in _pin_name_tokens(cons, pin_num))
def _is_active_low_reset(
cons: ComponentConstraints | None, pin_num: str, net_name: str,
) -> bool:
if _ACTIVE_LOW_RESET_RE.search(net_name or ""):
return True
return any(_ACTIVE_LOW_RESET_RE.search(t) for t in _pin_name_tokens(cons, pin_num))
def _other_ic_on_net(graph: DesignGraph, net_name: str, self_ref: str) -> bool:
for ref in graph.components_on_net(net_name):
if ref == self_ref:
continue
other = graph.components.get(ref)
if other and other.component_type == ComponentType.IC:
return True
return False
+157
View File
@@ -0,0 +1,157 @@
"""Stronger datasheet page text: reading-order blocks + table markdown.
Used by DeepSeek PDF ingest (review/extraction) and by quote verification
so both see the same reconstructed page.
"""
from __future__ import annotations
import logging
import re
from pathlib import Path
log = logging.getLogger(__name__)
_SPARSE_CHARS = 80
def pdf_page_texts(pdf_path: Path | str) -> list[str]:
"""1-based page texts (index 0 unused). Empty list if the file cannot be read."""
path = Path(pdf_path)
blob = _pages_pymupdf(path)
if blob is None:
blob = _pages_pypdf(path)
return blob
def extract_pdf_document_text(pdf_path: Path | str, *, max_chars: int) -> str:
"""Full datasheet dump with ``--- page N ---`` markers, truncated."""
path = Path(pdf_path)
pages = pdf_page_texts(path)
n = max(0, len(pages) - 1)
parts = [f"[PDF: {path.name}, {n} pages]"]
for i in range(1, n + 1):
body = (pages[i] or "").strip()
parts.append(f"--- page {i} ---\n{body}")
blob = "\n\n".join(parts)
if len(blob) > max_chars:
# Count how many page markers survive the cut for observability.
kept = blob[:max_chars].count("--- page ")
log.info(
"PDF text truncated: %s full=%d chars cap=%d kept_pages≈%d/%d",
path.name, len(blob), max_chars, kept, n,
)
blob = blob[:max_chars] + "\n\n[truncated: remaining pages omitted]"
return blob
def page_is_sparse(text: str) -> bool:
compact = re.sub(r"\s+", "", text or "")
return len(compact) < _SPARSE_CHARS
def _pages_pymupdf(pdf_path: Path) -> list[str] | None:
try:
import fitz
except ImportError:
return None
try:
doc = fitz.open(str(pdf_path))
except Exception as exc:
log.warning("PyMuPDF failed to open %s: %s", pdf_path, exc)
return None
try:
pages = [""]
for page in doc:
pages.append(fitz_page_text(page))
return pages
finally:
doc.close()
def _pages_pypdf(pdf_path: Path) -> list[str]:
from pypdf import PdfReader
try:
reader = PdfReader(str(pdf_path))
except Exception as exc:
log.warning("pypdf failed to open %s: %s", pdf_path, exc)
return []
pages = [""]
for page in reader.pages:
try:
pages.append(page.extract_text() or "")
except Exception:
pages.append("")
return pages
def fitz_page_text(page) -> str:
"""Reading-order text plus any reconstructed tables; flag sparse scans."""
tables = _table_markdown(page)
blocks = _blocks_text(page)
chunks = [c for c in (blocks, tables) if c]
text = "\n\n".join(chunks).strip()
if page_is_sparse(text):
note = "[low-text page: diagram or scan — use the page image]"
text = f"{text}\n{note}".strip() if text else note
return text
def _blocks_text(page) -> str:
try:
blocks = page.get_text("blocks") or []
except Exception:
try:
return (page.get_text("text") or "").strip()
except Exception:
return ""
lines: list[str] = []
# (x0, y0, x1, y1, text, block_no, block_type, ...)
textual = [b for b in blocks if len(b) >= 5 and str(b[4]).strip()]
textual.sort(key=lambda b: (round(float(b[1]) / 6.0), float(b[0])))
for b in textual:
piece = str(b[4]).strip()
if piece:
lines.append(piece)
if lines:
return "\n".join(lines)
try:
return (page.get_text("text") or "").strip()
except Exception:
return ""
def _table_markdown(page) -> str:
try:
finder = page.find_tables()
except Exception:
return ""
tables = getattr(finder, "tables", None) or []
chunks: list[str] = []
for table in tables:
md = _one_table_markdown(table)
if md:
chunks.append(md)
return "\n\n".join(chunks)
def _one_table_markdown(table) -> str:
try:
md = table.to_markdown()
if md and md.strip():
return md.strip()
except Exception:
pass
try:
rows = table.extract()
except Exception:
return ""
if not rows:
return ""
out: list[str] = []
for row in rows:
cells = [re.sub(r"\s+", " ", str(c or "")).strip() for c in row]
if any(cells):
out.append("| " + " | ".join(cells) + " |")
return "\n".join(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)
+175
View File
@@ -0,0 +1,175 @@
"""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.periscopex.models import (
ComponentConstraints,
ComponentType,
DesignGraph,
Finding,
)
from backend.periscopex.pin_function_tokens import (
complement,
normalize_functions,
parse_net_token,
signals_for_peripheral,
)
from backend.periscopex.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",
net=net_name,
pins=[f"{ref}.{pin_num}"],
rule_id="PE-MUX-001",
)
+315
View File
@@ -0,0 +1,315 @@
"""G2: decoupling proximity on the PCB vs datasheet layout_rules.
Runs only when a LayoutGraph is present and a decoupling_proximity rule
has a numeric max_distance_mm. Null millimetres skip — no 3 mm default.
Thermal vias (`PE-PLC-002`) skip without courtyard vertices and without
min_via_count — no invented pad radius. same_layer (`PE-PLC-003`) uses
the boolean parameter plus footprint layers from the PCB. Crystals use
the same decoupling_proximity rule. Track length is shortest path on
segments vs max_distance_mm — no invented “much larger than euclidean”.
Keepout (`PE-PLC-004`) is a foreign net endpoint inside the courtyard.
"""
from __future__ import annotations
import heapq
import math
from backend.periscopex.models import (
ComponentConstraints,
ComponentType,
DesignGraph,
Finding,
LayoutGraph,
LayoutPad,
)
from backend.periscopex.validate import _match_constraints
def _pad_for(layout: LayoutGraph, ref: str, number: str) -> LayoutPad | None:
fp = layout.footprints.get(ref)
if not fp:
return None
for pad in fp.pads:
if pad.number == str(number):
return pad
return None
def _pin_number(cons: ComponentConstraints, token: str) -> str | None:
want = str(token).strip()
if not want:
return None
for pin in cons.pintable:
if str(pin.number) == want or (pin.name or "").upper() == want.upper():
return str(pin.number)
return None
def _dist(a: LayoutPad, b: LayoutPad) -> float:
return math.hypot(a.x - b.x, a.y - b.y)
def _xy_key(x: float, y: float) -> tuple[float, float]:
return (round(x, 3), round(y, 3))
def _path_mm(layout: LayoutGraph, net: str, a: LayoutPad, b: LayoutPad) -> float | None:
segs = [s for s in layout.segments if s.net == net]
if not segs:
return None
adj: dict[tuple[float, float], list[tuple[tuple[float, float], float]]] = {}
for s in segs:
p = _xy_key(s.start[0], s.start[1])
q = _xy_key(s.end[0], s.end[1])
length = math.hypot(s.end[0] - s.start[0], s.end[1] - s.start[1])
adj.setdefault(p, []).append((q, length))
adj.setdefault(q, []).append((p, length))
src = _xy_key(a.x, a.y)
dst = _xy_key(b.x, b.y)
if src not in adj or dst not in adj:
return None
dist = {src: 0.0}
heap: list[tuple[float, tuple[float, float]]] = [(0.0, src)]
while heap:
d, node = heapq.heappop(heap)
if d > dist.get(node, math.inf):
continue
if node == dst:
return d
for nxt, w in adj.get(node, []):
nd = d + w
if nd < dist.get(nxt, math.inf):
dist[nxt] = nd
heapq.heappush(heap, (nd, nxt))
return None
def _reach_mm(layout: LayoutGraph, net: str, a: LayoutPad, b: LayoutPad) -> float:
path = _path_mm(layout, net, a, b)
if path is None:
return _dist(a, b)
return path
def _net_for_pin(graph: DesignGraph, ref: str, pin_no: str) -> str | None:
for net in graph.nets.values():
for pc in net.pins:
if pc.component_ref == ref and str(pc.pin_number) == str(pin_no):
return net.name
return graph.pin_net(ref, pin_no)
def check_placement(
graph: DesignGraph,
constraints_map: dict,
layout: LayoutGraph | None,
) -> list[Finding]:
if layout is None or not layout.footprints:
return []
findings: list[Finding] = []
for ref, comp in graph.components.items():
if comp.component_type not in (ComponentType.IC, ComponentType.CRYSTAL):
continue
cons = _match_constraints(comp.mpn, constraints_map)
if not cons or not cons.layout_rules:
continue
for rule in cons.layout_rules:
kind = rule.get("kind")
if kind == "decoupling_proximity":
findings.extend(
_decoupling_finding(ref, comp, cons, rule, graph, layout)
)
findings.extend(
_same_layer_finding(ref, comp, cons, rule, graph, layout)
)
elif kind == "thermal_via":
findings.extend(_thermal_via_finding(ref, comp, cons, rule, layout))
elif kind == "keepout":
findings.extend(_keepout_finding(ref, comp, cons, rule, graph, layout))
return findings
def _decoupling_finding(ref, comp, cons, rule, graph: DesignGraph, layout: LayoutGraph) -> list[Finding]:
pin_no = _pin_number(cons, str(rule.get("pin") or ""))
if not pin_no:
return []
net = _net_for_pin(graph, ref, pin_no)
if not net:
return []
ic_pad = _pad_for(layout, ref, pin_no)
if not ic_pad:
return []
cap_pads: list[LayoutPad] = []
for cref in graph.capacitors_on_net(net):
fp = layout.footprints.get(cref)
if not fp:
continue
for pad in fp.pads:
if pad.net == net or pad.net == ic_pad.net:
cap_pads.append(pad)
if not cap_pads:
return []
nearest = min(_reach_mm(layout, net, ic_pad, p) for p in cap_pads)
extracted = rule.get("max_distance_mm")
if extracted is None:
return []
limit = float(extracted)
if nearest <= limit:
return []
return [Finding(
designator=ref,
mpn=comp.mpn or cons.mpn,
aspect="placement",
finding=(
f"Decoupling on {net} is {nearest:.1f} mm from {ref}.{pin_no} "
f"(limit {limit:g} mm)."
),
why=f"layout_rules max_distance_mm={limit:g}.",
status="ERROR",
recommendation="Place the decoupling capacitor closer to the supply pin.",
source="placement_check",
rule_id="PE-PLC-001",
net=net,
pins=[pin_no],
source_page=rule.get("source_page"),
)]
def _copper_side(layer: str) -> str | None:
s = (layer or "").strip().upper()
if s.startswith("F."):
return "F"
if s.startswith("B."):
return "B"
return None
def _same_layer_finding(ref, comp, cons, rule, graph: DesignGraph, layout: LayoutGraph) -> list[Finding]:
if rule.get("same_layer") is not True:
return []
pin_no = _pin_number(cons, str(rule.get("pin") or ""))
if not pin_no:
return []
net = _net_for_pin(graph, ref, pin_no)
if not net:
return []
ic_fp = layout.footprints.get(ref)
if not ic_fp:
return []
ic_side = _copper_side(ic_fp.layer)
if ic_side is None:
return []
placed = []
for cref in graph.capacitors_on_net(net):
fp = layout.footprints.get(cref)
if not fp:
continue
side = _copper_side(fp.layer)
if side is None:
continue
placed.append((cref, side, fp))
if not placed:
return []
if any(side == ic_side for _, side, _ in placed):
return []
if len(ic_fp.courtyard) >= 3:
for v in layout.vias:
if v.net and v.net != net:
continue
if _in_poly(v.x, v.y, ic_fp.courtyard):
return []
return [Finding(
designator=ref,
mpn=comp.mpn or cons.mpn,
aspect="placement",
finding=(
f"Decoupling on {net} is on the opposite copper from {ref} "
f"(same_layer=true)."
),
why="layout_rules same_layer=true.",
status="WARNING",
recommendation="Place the decoupling capacitor on the same layer or add a via in the courtyard.",
source="placement_check",
rule_id="PE-PLC-003",
net=net,
pins=[pin_no],
source_page=rule.get("source_page"),
)]
def _in_poly(x: float, y: float, poly: list[tuple[float, float]]) -> bool:
n = len(poly)
inside = False
j = n - 1
for i in range(n):
xi, yi = poly[i]
xj, yj = poly[j]
if (yi > y) != (yj > y) and x < (xj - xi) * (y - yi) / (yj - yi) + xi:
inside = not inside
j = i
return inside
def _thermal_via_finding(ref, comp, cons, rule, layout: LayoutGraph) -> list[Finding]:
min_n = rule.get("min_via_count")
if min_n is None:
return []
fp = layout.footprints.get(ref)
if not fp or len(fp.courtyard) < 3:
return []
n = sum(1 for v in layout.vias if _in_poly(v.x, v.y, fp.courtyard))
if n >= int(min_n):
return []
pin = str(rule.get("pin") or "").strip()
return [Finding(
designator=ref,
mpn=comp.mpn or cons.mpn,
aspect="placement",
finding=(
f"{n} thermal vias in courtyard of {ref} "
f"(min_via_count {int(min_n)})."
),
why=f"layout_rules min_via_count={int(min_n)}.",
status="ERROR",
recommendation="Add vias in the thermal pad courtyard.",
source="placement_check",
rule_id="PE-PLC-002",
pins=[pin] if pin else [],
source_page=rule.get("source_page"),
)]
def _keepout_finding(ref, comp, cons, rule, graph: DesignGraph, layout: LayoutGraph) -> list[Finding]:
fp = layout.footprints.get(ref)
if not fp or len(fp.courtyard) < 3:
return []
pin_no = _pin_number(cons, str(rule.get("pin") or ""))
own = _net_for_pin(graph, ref, pin_no) if pin_no else None
if not own:
return []
foreign: list[str] = []
for s in layout.segments:
if not s.net or s.net == own:
continue
if _in_poly(s.start[0], s.start[1], fp.courtyard) or _in_poly(
s.end[0], s.end[1], fp.courtyard
):
foreign.append(s.net)
if not foreign:
return []
net = sorted(set(foreign))[0]
return [Finding(
designator=ref,
mpn=comp.mpn or cons.mpn,
aspect="placement",
finding=f"Track on {net} enters courtyard of {ref} (keepout on {own}).",
why="layout_rules kind=keepout.",
status="WARNING",
recommendation="Keep other nets out of the courtyard.",
source="placement_check",
rule_id="PE-PLC-004",
net=net,
pins=[pin_no],
source_page=rule.get("source_page"),
)]
+188
View File
@@ -0,0 +1,188 @@
"""Layout F2 skeleton — propose satellite xy from PCB anchors + numeric rules.
No millimetres are invented. Packing runs only when a LayoutGraph has
footprints and at least one ``decoupling_proximity`` rule carries a numeric
``max_distance_mm``. Otherwise the report is ``skipped`` with an explicit reason.
"""
from __future__ import annotations
import math
from typing import Literal
from pydantic import BaseModel
from backend.periscopex.functional_groups import FunctionalGroupsReport, PlacementIcGroup
from backend.periscopex.models import DesignGraph, LayoutGraph, LayoutPad
SkipReason = Literal[
"no_pcb_footprints",
"no_numeric_layout_rules",
"no_packable_satellites",
]
class PlacementProposal(BaseModel):
ref: str
anchor_ref: str
rule_kind: str
max_distance_mm: float
proposed_x: float
proposed_y: float
layer: str = ""
basis: str = "ic_pad+rule"
class PlacementPackReport(BaseModel):
objective: Literal["routing"] = "routing"
status: Literal["packed", "skipped"] = "skipped"
skip_reason: SkipReason | None = None
placements: list[PlacementProposal] = []
def build_placement_pack(
plan: FunctionalGroupsReport,
layout: LayoutGraph | None,
graph: DesignGraph | None = None,
) -> PlacementPackReport:
"""Propose satellite positions within extracted proximity limits."""
if layout is None or not layout.footprints:
return PlacementPackReport(status="skipped", skip_reason="no_pcb_footprints")
if not _has_numeric_proximity(plan):
return PlacementPackReport(
status="skipped",
skip_reason="no_numeric_layout_rules",
)
placements: list[PlacementProposal] = []
used_refs: set[str] = set()
for group in plan.groups:
placements.extend(
_pack_group(group, layout, graph, used_refs),
)
if not placements:
return PlacementPackReport(
status="skipped",
skip_reason="no_packable_satellites",
)
return PlacementPackReport(status="packed", placements=placements)
def _has_numeric_proximity(plan: FunctionalGroupsReport) -> bool:
for g in plan.groups:
for rule in g.layout_rules:
if rule.get("kind") != "decoupling_proximity":
continue
if _num(rule.get("max_distance_mm")) is not None:
return True
return False
def _num(raw) -> float | None:
if raw is None or isinstance(raw, bool):
return None
try:
v = float(raw)
except (TypeError, ValueError):
return None
return v if v > 0 else None
def _pack_group(
group: PlacementIcGroup,
layout: LayoutGraph,
graph: DesignGraph | None,
used_refs: set[str],
) -> list[PlacementProposal]:
ic_fp = layout.footprints.get(group.ref)
if not ic_fp:
return []
candidates = [
s for s in group.satellites
if s.role_hint in ("decoupling", "bulk") and s.ref not in used_refs
]
if not candidates:
return []
out: list[PlacementProposal] = []
for rule in group.layout_rules:
if rule.get("kind") != "decoupling_proximity":
continue
limit = _num(rule.get("max_distance_mm"))
if limit is None:
continue
pad = _anchor_pad(group, layout, graph, str(rule.get("pin") or ""))
if pad is None:
# Fall back to footprint origin when pin is unknown but rule is numeric.
pad = LayoutPad(number="", x=ic_fp.x, y=ic_fp.y, net="")
basis = "ic_origin+rule"
else:
basis = "ic_pad+rule"
net = pad.net or None
matched = [
s for s in candidates
if s.ref not in used_refs and (not net or net in (s.nets or []))
]
if not matched:
matched = [s for s in candidates if s.ref not in used_refs]
if not matched:
continue
for i, sat in enumerate(matched):
angle = (2.0 * math.pi * i) / max(len(matched), 8)
radius = limit * 0.5
px = pad.x + radius * math.cos(angle)
py = pad.y + radius * math.sin(angle)
layer = ic_fp.layer or ""
out.append(PlacementProposal(
ref=sat.ref,
anchor_ref=group.ref,
rule_kind="decoupling_proximity",
max_distance_mm=limit,
proposed_x=round(px, 4),
proposed_y=round(py, 4),
layer=layer,
basis=basis,
))
used_refs.add(sat.ref)
# One numeric rule per IC is enough for the skeleton.
break
return out
def _anchor_pad(
group: PlacementIcGroup,
layout: LayoutGraph,
graph: DesignGraph | None,
pin_token: str,
) -> LayoutPad | None:
fp = layout.footprints.get(group.ref)
if not fp or not fp.pads:
return None
want = (pin_token or "").strip()
if want:
for pad in fp.pads:
if pad.number == want:
return pad
if graph is not None:
comp = graph.components.get(group.ref)
if comp:
for pin_num, net in comp.pins.items():
if str(pin_num) == want:
for pad in fp.pads:
if pad.number == str(pin_num):
return pad
# No matching pad number — pick any pad on that net.
for pad in fp.pads:
if pad.net and pad.net == net:
return pad
# Prefer a pad on a power-looking net shared with decoupling sats.
for pad in fp.pads:
if pad.net:
return pad
return fp.pads[0]
+183
View File
@@ -0,0 +1,183 @@
"""Regulator current margin and explicit series-R IR drop.
Iout_max is the rating, never the load. IQ/load are summed only when every
IC on the rail has a spec. Trace resistance is never estimated.
"""
from __future__ import annotations
from backend.periscopex.led_current_check import _net_voltage, _parse_resistance
from backend.periscopex.models import (
Component,
ComponentConstraints,
ComponentType,
DesignGraph,
Finding,
InductorSpecs,
ResistorSpecs,
)
from backend.periscopex.passive_rail_check import _is_ground_net
from backend.periscopex.thermal_check import (
_IOUT_MAX_KEYS,
_LOAD_KEYS,
_VIN_PIN,
_VOUT_PIN,
_first,
_is_ldo,
_pin_net_by_role,
_specs_values,
)
from backend.periscopex.validate import _match_constraints
_IQ_KEYS = (
"iq_a", "quiescent_current_a", "supply_current_a", "idd_a", "icc_a",
)
_IR_FRAC = 0.05 # 5% of the rail — wide, not a datasheet number
def _two_nets(comp: Component) -> tuple[str, str] | None:
nets = list(dict.fromkeys(comp.pins.values()))
if len(nets) != 2:
return None
return nets[0], nets[1]
def _series_ohms(comp: Component) -> float | None:
if comp.component_type == ComponentType.RESISTOR:
if isinstance(comp.specs, ResistorSpecs) and comp.specs.value_ohms >= 0:
return float(comp.specs.value_ohms)
return _parse_resistance(comp.value)
if comp.component_type == ComponentType.INDUCTOR:
if isinstance(comp.specs, InductorSpecs) and comp.specs.dcr_ohms is not None:
return float(comp.specs.dcr_ohms)
return None
def _expand_rail(graph: DesignGraph, start: str) -> set[str]:
"""Follow series R/L between nets; do not walk through ICs (VIN/VOUT)."""
seen = {start}
stack = [start]
while stack:
n = stack.pop()
for ref in graph.components_on_net(n):
c = graph.components.get(ref)
if not c or c.component_type not in (
ComponentType.RESISTOR, ComponentType.INDUCTOR,
):
continue
pair = _two_nets(c)
if not pair:
continue
other = pair[1] if pair[0] == n else pair[0]
if other in seen or _is_ground_net(graph, other):
continue
seen.add(other)
stack.append(other)
return seen
def _regulators(graph: DesignGraph, cmap: dict[str, ComponentConstraints]):
for ref, comp in sorted(graph.components.items()):
if comp.component_type != ComponentType.IC:
continue
cons = _match_constraints(comp.mpn or comp.value, cmap)
vin = _pin_net_by_role(graph, comp, cons, _VIN_PIN)
vout = _pin_net_by_role(graph, comp, cons, _VOUT_PIN)
if not (vin and vout) and not _is_ldo(comp, cons):
continue
if not (vin and vout):
continue
yield ref, comp, cons, vin, vout
def check_power_margin(
graph: DesignGraph,
constraints_map: dict[str, ComponentConstraints] | None = None,
) -> list[Finding]:
cmap = constraints_map or {}
findings: list[Finding] = []
for ref, comp, cons, vin, vout in _regulators(graph, cmap):
values = _specs_values(comp)
iout_max = _first(values, _IOUT_MAX_KEYS)
i_load = _first(values, _LOAD_KEYS)
ics: list[Component] = []
missing_iq = False
iq_sum = 0.0
for net in _expand_rail(graph, vout):
for r in graph.components_on_net(net):
c = graph.components.get(r)
if not c or c.component_type != ComponentType.IC or r == ref:
continue
if c in ics:
continue
ics.append(c)
iq = _first(_specs_values(c), _IQ_KEYS)
if iq is None:
missing_iq = True
else:
iq_sum += iq
i_total = None
if i_load is not None and not missing_iq:
i_total = i_load + iq_sum
elif i_load is not None and not ics:
i_total = i_load
elif not missing_iq and ics and i_load is None:
i_total = iq_sum
if iout_max is not None and i_total is not None and i_total > iout_max:
findings.append(Finding(
designator=ref,
mpn=comp.mpn or "",
aspect="power",
source="power_margin_check",
status="WARNING",
finding=(
f"{ref} load ≈ {i_total:.3g} A exceeds Iout_max {iout_max:.3g} A "
f"on '{vout}'."
),
why="Sum of specified IQ on the rail plus I_load. Missing IQ was not guessed.",
recommendation="Raise the regulator rating or cut the load.",
reference="regulator Iout_max",
net=vout,
pins=[ref],
rule_id="PE-PWR-001",
))
# IR drop only through an explicit series R/ferrite on VIN or VOUT.
if i_load is None:
continue
for r in graph.components_on_net(vin):
c = graph.components.get(r)
if not c or c.component_type not in (
ComponentType.RESISTOR, ComponentType.INDUCTOR,
):
continue
pair = _two_nets(c)
if not pair:
continue
ohms = _series_ohms(c)
if ohms is None or ohms <= 0:
continue
drop = i_load * ohms
vrail = _net_voltage(graph, vin) or _net_voltage(graph, vout)
if vrail is None or vrail <= 0:
continue
if drop <= _IR_FRAC * vrail:
continue
findings.append(Finding(
designator=r,
mpn=c.mpn or "",
aspect="power",
source="power_margin_check",
status="WARNING",
finding=(
f"{r} series drop ≈ {drop:.3g} V at I_load={i_load:.3g} A "
f"into {ref} VIN '{vin}'."
),
why="IR from an explicit series R/ferrite DCR. Trace resistance was not estimated.",
recommendation="Lower DCR or the load, or accept the drop if it is intended.",
reference="netlist series R",
net=vin,
pins=[r],
rule_id="PE-PWR-001",
))
return findings
+154
View File
@@ -0,0 +1,154 @@
"""Deterministic check that a finding's datasheet quote is actually in the PDF.
The reviewer must cite verbatim text. This module extracts page text (PyMuPDF,
then pypdf) and looks for a normalized match on the cited page ±1. Failures
demote ERROR → WARNING and prefix ``why`` with ``Unverified:``.
"""
from __future__ import annotations
import re
from collections.abc import Callable
from pathlib import Path
from backend.periscopex.models import Finding
from backend.periscopex.pdf_text import pdf_page_texts as extract_pdf_pages
from backend.periscopex.utils import safe_mpn
_MIN_QUOTE_CHARS = 12
_EMPTY_PAGE_ALNUM = 40
_PAGE_WINDOW = 1
def normalize_quote(s: str) -> str:
"""Fold µ/μ, drop soft hyphens and linebreak hyphenation, squeeze space."""
t = (s or "").replace("µ", "μ").replace("\u00ad", "")
t = re.sub(r"-\s+", "", t)
t = re.sub(r"\s+", " ", t).strip().lower()
return t
def _alnum(s: str) -> str:
return re.sub(r"[^a-z0-9μ]+", "", normalize_quote(s))
def quote_in_text(quote: str, text: str) -> bool:
"""True if *quote* appears in *text* after the same folding the PDF viewer uses."""
q = normalize_quote(quote)
if len(q) < _MIN_QUOTE_CHARS:
return False
hay = normalize_quote(text)
if q in hay:
return True
qa, ha = _alnum(quote), _alnum(text)
return len(qa) >= _MIN_QUOTE_CHARS and qa in ha
def pdf_page_texts(pdf_path: Path) -> list[str]:
"""1-based page texts (index 0 unused). Empty list if the file cannot be read."""
return extract_pdf_pages(pdf_path)
def locate_quote(
pdf_path: Path,
page: int | None,
quote: str,
*,
window: int = _PAGE_WINDOW,
) -> tuple[str, int | None]:
"""Return ``(ok|missing_quote|not_found|page_empty|no_pdf|bad_page, matched_page)``."""
q = (quote or "").strip()
if len(normalize_quote(q)) < _MIN_QUOTE_CHARS:
return ("missing_quote", None)
if not pdf_path.is_file():
return ("no_pdf", None)
pages = pdf_page_texts(pdf_path)
n = len(pages) - 1
if n < 1:
return ("no_pdf", None)
if page is None or not isinstance(page, int) or page < 1:
# Search the whole file; keep the first hit.
for i in range(1, n + 1):
if quote_in_text(q, pages[i]):
return ("ok", i)
if max(len(_alnum(p)) for p in pages[1:]) < _EMPTY_PAGE_ALNUM:
return ("page_empty", None)
return ("not_found", None)
lo = max(1, page - window)
hi = min(n, page + window)
matched: int | None = None
any_text = False
for i in range(lo, hi + 1):
if len(_alnum(pages[i])) >= _EMPTY_PAGE_ALNUM:
any_text = True
if quote_in_text(q, pages[i]):
matched = i
break
if matched is not None:
return ("ok", matched)
if not any_text:
return ("page_empty", None)
if page > n:
return ("bad_page", None)
return ("not_found", None)
_REASONS = {
"missing_quote": "no verbatim datasheet quote.",
"not_found": "cited text not found on the datasheet page.",
"page_empty": "cited page has no extractable text (figure or scan).",
"no_pdf": "datasheet PDF unavailable to check the quote.",
"bad_page": "source_page missing or out of range.",
}
def _mark_unverified(finding: Finding, reason_key: str) -> None:
if finding.status == "ERROR":
finding.status = "WARNING"
msg = _REASONS[reason_key]
if not finding.why.startswith("Unverified:"):
finding.why = f"Unverified: {msg} {finding.why}".strip()
def verify_finding_citations(
findings: list[Finding],
*,
default_pdf: Path,
default_mpn: str,
pdf_dir: Path | None = None,
mpn_by_designator: dict[str, str] | None = None,
pdf_for_mpn: Callable[[str], Path | None] | None = None,
) -> None:
"""Mutate *findings* in place: check each ``source_quote`` against the PDF."""
mpn_by_designator = mpn_by_designator or {}
pdf_dir = pdf_dir or default_pdf.parent
cache: dict[str, Path | None] = {}
def resolve_pdf(finding: Finding) -> Path:
mpn = default_mpn
if finding.source_designator:
mpn = mpn_by_designator.get(finding.source_designator) or default_mpn
if pdf_for_mpn is not None:
hit = pdf_for_mpn(mpn)
if hit is not None:
return hit
key = mpn
if key not in cache:
p = pdf_dir / f"{safe_mpn(mpn)}.pdf"
cache[key] = p if p.is_file() else None
return cache[key] or default_pdf
for finding in findings:
pdf = resolve_pdf(finding)
reason, matched = locate_quote(pdf, finding.source_page, finding.source_quote)
if reason == "ok":
if matched is not None and finding.source_page != matched:
finding.source_page = matched
finding.reference = re.sub(
r"p\.\S+$",
f"p.{matched}",
finding.reference or f"{default_mpn} datasheet p.{matched}",
)
continue
_mark_unverified(finding, reason)
+576
View File
@@ -0,0 +1,576 @@
"""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.periscopex.models import (
CapacitorSpecs,
ComponentSpecs,
ComponentType,
InductorSpecs,
PassivePattern,
ResistorSpecs,
ResolvedPassive,
SimpleComponentSpecs,
ValueDecoder,
)
from backend.periscopex.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 == "passive.ferrite_bead":
raw = vals.get("impedance_ohm") or vals.get("value_ohms")
if raw is None:
raise ValueError("Missing impedance_ohm in auto-resolved ferrite bead specs")
impedance_ohm = _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)
formatted = value_formatted or _format_value(impedance_ohm, "ohm")
return InductorSpecs(
component_subtype=subtype_for_specs,
value_henries=None,
value_formatted=formatted,
tolerance=tolerance,
package=package,
current_rating_a=current_rating_a,
dcr_ohms=dcr_ohms,
impedance_ohm=impedance_ohm,
)
if subtype.startswith("passive.inductor"):
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()
+76
View File
@@ -0,0 +1,76 @@
"""Stable per-IC neighborhood hash so a second review can skip unchanged chips."""
from __future__ import annotations
import hashlib
import json
from backend.periscopex.models import ComponentType, DesignGraph
from backend.periscopex.validate import _match_constraints
def ic_neighborhood_fingerprint(
graph: DesignGraph,
ref: str,
constraints_map: dict | None = None,
) -> str | None:
"""Hash MPN, pin→net, 1-hop neighbors, and extraction model_version.
Returns None if *ref* is not an IC. Neighbor changes (pull-up added on
SDA, etc.) invalidate every IC on that net.
"""
comp = graph.components.get(ref)
if not comp or comp.component_type != ComponentType.IC:
return None
pins = tuple(sorted((str(p), n) for p, n in comp.pins.items()))
neighbors: list[tuple[str, str, str, str]] = []
for _pin, net_name in pins:
for other in graph.components_on_net(net_name):
if other == ref:
continue
o = graph.components[other]
o_pins_on_net = tuple(
sorted(str(p) for p, n in o.pins.items() if n == net_name)
)
neighbors.append(
(other, o.mpn or "", o.component_type.value, ",".join(o_pins_on_net))
)
model_version = ""
cons = _match_constraints(comp.mpn or comp.value, constraints_map or {})
if cons is not None:
model_version = getattr(cons, "model_version", "") or ""
payload = {
"mpn": comp.mpn or "",
"pins": pins,
"neighbors": tuple(sorted(neighbors)),
"model_version": model_version,
}
blob = json.dumps(payload, sort_keys=True, default=str).encode()
return hashlib.sha256(blob).hexdigest()
def graph_ic_fingerprints(
graph: DesignGraph,
constraints_map: dict | None = None,
) -> dict[str, str]:
out: dict[str, str] = {}
for ref, comp in graph.components.items():
if comp.component_type != ComponentType.IC:
continue
fp = ic_neighborhood_fingerprint(graph, ref, constraints_map)
if fp:
out[ref] = fp
return out
def skip_unchanged_ics(
completed_refs: set[str],
previous: dict[str, str],
current: dict[str, str],
) -> set[str]:
"""Keep skip only for completed ICs whose neighborhood hash is unchanged."""
skip: set[str] = set()
for ref in completed_refs:
if ref in current and previous.get(ref) == current[ref]:
skip.add(ref)
return skip
+107
View File
@@ -0,0 +1,107 @@
"""Finding review disposition, ECO export, and release signature.
Review state lives beside comments on the report JSON — it is not a
Finding field, so a pipeline re-run can keep dispositions by finding_id.
Empty reason is invalid. false_positive / wontfix / open are not ECO rows.
"""
from __future__ import annotations
import csv
import hashlib
import io
import json
from datetime import datetime, timezone
from typing import Any, Iterable, Literal
from backend.periscopex.models import Finding
ReviewState = Literal["open", "false_positive", "accepted", "wontfix"]
VALID_STATES: frozenset[str] = frozenset({"open", "false_positive", "accepted", "wontfix"})
class ReviewError(ValueError):
"""Invalid review payload; do not store a silent default."""
def apply_review_state(
current: dict[str, dict[str, Any]],
finding_id: str,
*,
state: str,
reason: str,
user_id: str,
user_name: str = "",
updated_at: str | None = None,
) -> dict[str, dict[str, Any]]:
if not finding_id:
raise ReviewError("finding_id is required")
if state not in VALID_STATES:
raise ReviewError(f"invalid review state {state!r}")
text = (reason or "").strip()
if state != "open" and not text:
raise ReviewError("reason is required")
rec = {
"state": state,
"reason": text,
"user_id": user_id,
"user_name": user_name,
"updated_at": updated_at or datetime.now(timezone.utc).isoformat(),
}
next_states = dict(current)
if state == "open":
next_states.pop(finding_id, None)
return next_states
next_states[finding_id] = rec
return next_states
def _state_of(states: dict[str, dict[str, Any]], finding_id: str | None) -> str:
if not finding_id:
return "open"
rec = states.get(finding_id)
if not rec:
return "open"
return rec.get("state") or "open"
def build_eco(
findings: Iterable[Finding],
review_states: dict[str, dict[str, Any]],
) -> list[dict[str, str]]:
items: list[dict[str, str]] = []
for f in findings:
fid = f.finding_id
if _state_of(review_states, fid) != "accepted":
continue
rec = review_states.get(fid or "", {})
items.append({
"finding_id": fid or "",
"rule_id": f.rule_id or "",
"ref": f.designator,
"before": f.finding,
"after": f.recommendation or "",
"reason": rec.get("reason") or "",
})
return items
def eco_csv(items: list[dict[str, str]]) -> str:
buf = io.StringIO()
writer = csv.DictWriter(
buf,
fieldnames=["finding_id", "rule_id", "ref", "before", "after", "reason"],
)
writer.writeheader()
writer.writerows(items)
return buf.getvalue()
def sign_report(report: dict[str, Any], *, user_id: str, timestamp: str | None = None) -> dict[str, str]:
payload = json.dumps(report.get("findings") or [], sort_keys=True, default=str)
digest = hashlib.sha256(payload.encode("utf-8")).hexdigest()
return {
"sha256": digest,
"user_id": user_id,
"timestamp": timestamp or datetime.now(timezone.utc).isoformat(),
}
+109
View File
@@ -0,0 +1,109 @@
"""Power-good → enable sequencing when the IC specs declare a sequence.
No RC time constants are invented. Missing power_sequence means skip.
"""
from __future__ import annotations
import re
from backend.periscopex.models import (
ComponentConstraints,
ComponentType,
DesignGraph,
Finding,
)
from backend.periscopex.thermal_check import (
_VIN_PIN,
_VOUT_PIN,
_is_ldo,
_pin_net_by_role,
_specs_values,
)
from backend.periscopex.validate import _match_constraints
_PG_RE = re.compile(r"(?:^|[_/])(PG|PGOOD|PWRGD|POWER_GOOD|POK)(?:$|[_/\d])", re.I)
_EN_RE = re.compile(
r"(?:^|[_/])(EN|ENA|ENABLE|n?SHDN|nEN|EN_N)(?:$|[_/\d])",
re.I,
)
def _has_sequence(comp) -> bool:
values = _specs_values(comp)
raw = values.get("power_sequence")
if raw is None or raw == "" or raw is False:
return False
if isinstance(raw, (int, float)) and raw == 0:
return False
return True
def check_power_sequencing(
graph: DesignGraph,
constraints_map: dict[str, ComponentConstraints] | None = None,
) -> list[Finding]:
cmap = constraints_map or {}
regs: list[tuple] = []
for ref, comp in sorted(graph.components.items()):
if comp.component_type != ComponentType.IC:
continue
cons = _match_constraints(comp.mpn or comp.value, cmap)
vin = _pin_net_by_role(graph, comp, cons, _VIN_PIN)
vout = _pin_net_by_role(graph, comp, cons, _VOUT_PIN)
if not (vin and vout) and not _is_ldo(comp, cons):
continue
pg = _pin_net_by_role(graph, comp, cons, _PG_RE, exclude_re=None)
en = _pin_net_by_role(graph, comp, cons, _EN_RE, exclude_re=None)
regs.append((ref, comp, cons, vin, vout, pg, en))
findings: list[Finding] = []
for dref, dcomp, dcons, dvin, _dvout, _dpg, den in regs:
if not _has_sequence(dcomp):
continue
if not den or not dvin:
continue
upstream = [
row for row in regs
if row[0] != dref and row[4] and row[4] == dvin
]
if not upstream:
continue
uref, ucomp, _ucons, _uvin, _uvout, upg, _uen = upstream[0]
if not upg:
findings.append(Finding(
designator=dref,
mpn=dcomp.mpn or "",
aspect="sequencing",
source="sequencing_check",
status="WARNING",
finding=(
f"{dref} specs declare power_sequence but upstream {uref} "
f"has no PG pin feeding {dref} EN '{den}'."
),
why="Sequence was listed in IC specs; delay milliseconds were not estimated.",
recommendation="Tie the upstream power-good to this enable, or remove the sequence spec if unused.",
reference="power_sequence",
net=den,
pins=[dref, uref],
rule_id="PE-SEQ-001",
))
continue
if upg != den:
findings.append(Finding(
designator=dref,
mpn=dcomp.mpn or "",
aspect="sequencing",
source="sequencing_check",
status="WARNING",
finding=(
f"{uref} PG '{upg}' does not connect to {dref} EN '{den}'."
),
why="Declared power_sequence expects PG to enable the next rail.",
recommendation="Net the upstream PG to the downstream EN.",
reference="power_sequence",
net=den,
pins=[f"{uref}", f"{dref}"],
rule_id="PE-SEQ-001",
))
return findings
+91
View File
@@ -0,0 +1,91 @@
"""G1 SI: intra-pair skew only when the datasheet gives millimetres.
Pair names (_DP/_DM, _P/_N) only identify which nets to compare. The
limit is never 3W, USB spec folklore, or a default millimetre.
"""
from __future__ import annotations
import math
from backend.periscopex.models import DesignGraph, Finding, LayoutGraph, LayoutSegment
from backend.periscopex.validate import _match_constraints
_PAIR_SUFFIXES = (("_DP", "_DM"), ("_P", "_N"), ("+", "-"))
def _seg_len(seg: LayoutSegment) -> float:
return math.hypot(seg.end[0] - seg.start[0], seg.end[1] - seg.start[1])
def net_length_mm(layout: LayoutGraph, net: str) -> float:
return sum(_seg_len(s) for s in layout.segments if s.net == net)
def partner_net(name: str) -> str | None:
for a, b in _PAIR_SUFFIXES:
if name.endswith(a):
return name[: -len(a)] + b
if name.endswith(b):
return name[: -len(b)] + a
return None
def _length_match_limit_mm(constraints_map: dict, graph: DesignGraph) -> tuple[float, int | None] | None:
for comp in graph.components.values():
cons = _match_constraints(comp.mpn, constraints_map)
if not cons:
continue
for rule in cons.layout_rules or []:
if rule.get("kind") != "length_match":
continue
mm = rule.get("max_distance_mm")
if mm is None:
continue
return float(mm), rule.get("source_page")
return None
def check_si(
graph: DesignGraph,
constraints_map: dict,
layout: LayoutGraph | None,
) -> list[Finding]:
if layout is None or not layout.segments:
return []
limit = _length_match_limit_mm(constraints_map, graph)
if limit is None:
return []
max_mm, page = limit
seen: set[tuple[str, str]] = set()
findings: list[Finding] = []
names = {s.net for s in layout.segments if s.net}
for net in names:
partner = partner_net(net)
if not partner or partner not in names:
continue
key = tuple(sorted((net, partner)))
if key in seen:
continue
seen.add(key)
skew = abs(net_length_mm(layout, net) - net_length_mm(layout, partner))
if skew <= max_mm:
continue
findings.append(Finding(
designator="layout",
mpn="",
aspect="si",
finding=(
f"Intra-pair skew {skew:.1f} mm on {key[0]}/{key[1]} "
f"(datasheet max {max_mm:g} mm)."
),
why=f"length_match max_distance_mm={max_mm:g}.",
status="ERROR",
recommendation="Length-match the differential pair.",
source="si_check",
rule_id="PE-SI-001",
net=net,
pins=[],
source_page=page,
))
return findings
+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()
+298
View File
@@ -0,0 +1,298 @@
"""Schematic thermal estimates for LDOs and dissipating resistors.
I_load is never inferred from Iout_max. θJA is never invented: missing
theta_ja after a known P is INFO only. Ta defaults to 25 °C.
"""
from __future__ import annotations
import re
from backend.periscopex.led_current_check import (
_leg_color,
_net_voltage,
_parse_resistance,
_series_resistor,
_vf,
)
from backend.periscopex.models import (
Component,
ComponentConstraints,
ComponentType,
DesignGraph,
Finding,
ResistorSpecs,
)
from backend.periscopex.passive_rail_check import _pin_name_tokens
from backend.periscopex.resolve_passives import _parse_spice_value
from backend.periscopex.validate import _match_constraints
_TA_C = 25.0
_TJ_WARN_C = 125.0
_LOAD_KEYS = (
"i_load", "i_load_a", "load_current_a", "typical_load_a",
"iout_typical_a", "typical_output_current_a",
)
_IOUT_MAX_KEYS = (
"iout_max", "iout_max_a", "i_out_max", "max_output_current_a",
"output_current_max_a",
)
_THETA_KEYS = ("theta_ja", "theta_ja_c_per_w", "thermal_resistance_ja", "rth_ja")
_VIN_PIN = re.compile(r"(?:^|[_/])(VIN|IN)(?:$|[_/\d])", re.I)
_VOUT_PIN = re.compile(r"(?:^|[_/])(VOUT|V_OUT|VO|OUT)(?:$|[_/\d])", re.I)
_NOT_OUT = re.compile(r"\b(EN|FB|NC|GND|PG)\b", re.I)
def _num(v: object) -> float | None:
if v is None:
return None
if isinstance(v, (int, float)):
return float(v)
s = str(v).strip()
try:
return _parse_spice_value(s)
except ValueError:
m = re.match(r"^[-+]?\d*\.?\d+", s)
if m:
try:
return float(m.group(0))
except ValueError:
return None
return None
def _specs_values(comp: Component) -> dict:
specs = comp.specs
values = getattr(specs, "values", None) if specs else None
return values if isinstance(values, dict) else {}
def _first(values: dict, keys: tuple[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 _power_rating_w(comp: Component) -> float | None:
specs = comp.specs
if isinstance(specs, ResistorSpecs) and specs.power_rating_w:
raw = specs.power_rating_w
s = str(raw).strip().upper().replace("W", "")
if "/" in s:
try:
a, b = s.split("/", 1)
return float(a) / float(b)
except (TypeError, ValueError):
pass
return _num(raw) or _num(s)
return None
def _is_ldo(comp: Component, cons: ComponentConstraints | None) -> bool:
sub = (comp.component_subtype or "") + " " + ((cons.component_subtype if cons else "") or "")
if "ldo" in sub.lower() or "linear_regulator" in sub.lower():
return True
return False
def _pin_net_by_role(
graph: DesignGraph,
comp: Component,
cons: ComponentConstraints | None,
role_re: re.Pattern,
exclude_re: re.Pattern | None = _NOT_OUT,
) -> str | None:
for pin_num, net in comp.pins.items():
tokens = _pin_name_tokens(cons, pin_num) or [pin_num]
if any(
role_re.search(t) and not (exclude_re and exclude_re.search(t))
for t in tokens
):
return net
if role_re.search(net or "") and not (exclude_re and exclude_re.search(net or "")):
return net
return None
def check_thermal(
graph: DesignGraph,
constraints_map: dict[str, ComponentConstraints] | None = None,
) -> list[Finding]:
cmap = constraints_map or {}
findings: list[Finding] = []
findings.extend(_ldo_thermal(graph, cmap))
findings.extend(_resistor_thermal(graph))
return findings
def _ldo_thermal(
graph: DesignGraph,
cmap: dict[str, ComponentConstraints],
) -> list[Finding]:
out: 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, cmap)
if not _is_ldo(comp, cons):
# VIN+VOUT names still count as a regulator for this check.
vin_n = _pin_net_by_role(graph, comp, cons, _VIN_PIN)
vout_n = _pin_net_by_role(graph, comp, cons, _VOUT_PIN)
if not (vin_n and vout_n):
continue
else:
vin_n = _pin_net_by_role(graph, comp, cons, _VIN_PIN)
vout_n = _pin_net_by_role(graph, comp, cons, _VOUT_PIN)
values = _specs_values(comp)
i_load = _first(values, _LOAD_KEYS)
if i_load is None:
# Explicitly ignore Iout_max — that is not a load.
continue
vin = _net_voltage(graph, vin_n) if vin_n else None
vout = _net_voltage(graph, vout_n) if vout_n else None
if vin is None or vout is None or vin <= vout:
continue
p = i_load * (vin - vout)
theta = _first(values, _THETA_KEYS)
net = vout_n or vin_n
if theta is None:
out.append(Finding(
designator=ref,
mpn=comp.mpn or "",
aspect="thermal",
source="thermal_check",
status="INFO",
finding=(
f"{ref} dissipation ≈ {p:.3g} W "
f"(I_load={i_load:.3g} A, Vin-Vout={vin - vout:.3g} V); "
f"manca theta_ja."
),
why="θJA is not in the IC specs; Tj is not estimated.",
recommendation="Add theta_ja (or θJA) from the datasheet package table.",
reference="thermal estimate",
net=net,
pins=[ref],
rule_id="PE-TH-001",
))
continue
tj = _TA_C + p * theta
status = "WARNING" if tj >= _TJ_WARN_C else "INFO"
rule = "PE-TH-002" if status == "WARNING" else "PE-TH-001"
out.append(Finding(
designator=ref,
mpn=comp.mpn or "",
aspect="thermal",
source="thermal_check",
status=status,
finding=(
f"{ref} Tj ≈ {tj:.0f} °C at Ta={_TA_C:.0f} °C "
f"(P≈{p:.3g} W, θJA={theta:.3g} °C/W)."
),
why="P = I_load × (VinVout); Tj = Ta + P·θJA. Iout_max was not used as load.",
recommendation="Lower I_load, drop, or θJA (better copper / package) if Tj is high.",
reference="thermal estimate",
net=net,
pins=[ref],
rule_id=rule,
))
return out
def _resistor_thermal(graph: DesignGraph) -> list[Finding]:
out: list[Finding] = []
seen: set[str] = set()
for ref in sorted(graph.components_by_subtype("discrete.led")):
led = graph.components.get(ref)
if not led or not led.specs:
continue
values = getattr(led.specs, "values", None) or {}
for pid, net in led.pins.items():
res = _series_resistor(graph, net, ref)
if not res:
continue
rref, rval, far = res
if rref in seen:
continue
rcomp = graph.components.get(rref)
rating = _power_rating_w(rcomp) if rcomp else None
if rating is None:
continue
color = _leg_color(pid, led)
vf = _vf(values, color)
vrail = _net_voltage(graph, far)
if vrail is None:
vrail = max(
(v for v in (_net_voltage(graph, n) for n in led.pins.values()) if v is not None),
default=None,
)
if vrail is None or vf is None or vrail <= vf or rval <= 0:
continue
i = (vrail - vf) / rval
p = i * i * rval
if p <= rating:
continue
seen.add(rref)
out.append(Finding(
designator=rref,
mpn=(rcomp.mpn if rcomp else "") or "",
aspect="thermal",
source="thermal_check",
status="WARNING",
finding=(
f"{rref} dissipates ≈ {p:.3g} W on the LED path, "
f"above its {rating:.3g} W rating."
),
why="P = I²R with I from (VrailVf)/R. Rating comes from power_rating_w.",
recommendation="Use a higher-wattage resistor or raise R to cut current.",
reference="resistor power rating",
net=net,
pins=[rref],
rule_id="PE-TH-003",
))
for ref, comp in sorted(graph.components.items()):
if ref in seen or comp.component_type != ComponentType.RESISTOR:
continue
rating = _power_rating_w(comp)
ohms = None
if isinstance(comp.specs, ResistorSpecs):
ohms = float(comp.specs.value_ohms)
if ohms is None:
ohms = _parse_resistance(comp.value)
if rating is None or ohms is None or ohms <= 0:
continue
nets = list(dict.fromkeys(comp.pins.values()))
if len(nets) != 2:
continue
v1, v2 = _net_voltage(graph, nets[0]), _net_voltage(graph, nets[1])
if v1 is None or v2 is None:
continue
dv = abs(v1 - v2)
if dv <= 0:
continue
i = dv / ohms
p = i * i * ohms
if p <= rating:
continue
out.append(Finding(
designator=ref,
mpn=comp.mpn or "",
aspect="thermal",
source="thermal_check",
status="WARNING",
finding=(
f"{ref} shunt dissipates ≈ {p:.3g} W "
f"(ΔV={dv:.3g} V / {ohms:.3g} Ω), above its {rating:.3g} W rating."
),
why="P = I²R with I = ΔV/R from known net voltages. No guessed current.",
recommendation="Raise the wattage rating or the resistance.",
reference="resistor power rating",
net=nets[0],
pins=[ref],
rule_id="PE-TH-003",
))
return out
+21
View File
@@ -0,0 +1,21 @@
"""Shared utility functions for the periscopex 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
+930
View File
@@ -0,0 +1,930 @@
"""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.periscopex.models import (
ComponentConstraints,
DesignGraph,
)
from backend.periscopex.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 shortest_path(
graph: DesignGraph,
constraints_map: ConstraintsMap,
designator_a: str,
pin_a: str,
designator_b: str,
pin_b: str,
*,
max_hops: int = 12,
) -> str:
"""BFS through the bipartite graph from A.pin to B.pin.
Hops alternate component→net→component. Returns the hop list or a
clear miss message. Caps depth so the reviewer cannot explode memory
on dense power nets.
"""
a = graph.components.get(designator_a)
b = graph.components.get(designator_b)
if not a:
return f"Component '{designator_a}' not found."
if not b:
return f"Component '{designator_b}' not found."
net_a = a.pins.get(str(pin_a))
net_b = b.pins.get(str(pin_b))
if not net_a:
return f"Pin {pin_a} on {designator_a} is not connected in the netlist."
if not net_b:
return f"Pin {pin_b} on {designator_b} is not connected in the netlist."
if designator_a == designator_b and str(pin_a) == str(pin_b):
return f"Same endpoint: {designator_a}.{pin_a} on {net_a}."
if net_a == net_b:
return (
f"Direct (same net): {designator_a}.{pin_a} —[{net_a}]— "
f"{designator_b}.{pin_b}"
)
# BFS on component nodes; edges are nets shared between components.
from collections import deque
start = designator_a
goal = designator_b
queue: deque[str] = deque([start])
# prev[ref] = (previous_ref, via_net)
prev: dict[str, tuple[str, str] | None] = {start: None}
hops = 0
found = False
while queue and hops < max_hops:
hops += 1
for _ in range(len(queue)):
cur = queue.popleft()
for net_name, others in graph.neighbors(cur).items():
for other in others:
if other in prev:
continue
prev[other] = (cur, net_name)
if other == goal:
found = True
queue.clear()
break
queue.append(other)
if found:
break
if found:
break
if not found or goal not in prev:
return (
f"No path within {max_hops} hops from "
f"{designator_a}.{pin_a} ({net_a}) to "
f"{designator_b}.{pin_b} ({net_b})."
)
# Reconstruct component chain, then decorate endpoints with pins.
chain_refs: list[str] = []
via_nets: list[str] = []
node = goal
while node != start:
chain_refs.append(node)
parent, via = prev[node] # type: ignore[misc]
via_nets.append(via)
node = parent
chain_refs.append(start)
chain_refs.reverse()
via_nets.reverse()
parts: list[str] = [f"{designator_a}.{pin_a}"]
for i, via in enumerate(via_nets):
nxt = chain_refs[i + 1]
if nxt == designator_b:
parts.append(f"—[{via}]— {designator_b}.{pin_b}")
else:
parts.append(f"—[{via}]— {nxt}")
return f"Path ({len(via_nets)} hop(s)): " + " ".join(parts)
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.
"""
from backend.services.datasheet_finder import find_local_pdf
local = find_local_pdf(state.pdf_dir, mpn)
if local is not None and local.is_file():
wanted = state.pdf_dir / f"{safe_mpn(mpn)}.pdf"
if local.resolve() != wanted.resolve() and not wanted.is_file():
wanted.write_bytes(local.read_bytes())
return wanted
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:
wanted = state.pdf_dir / f"{safe_mpn(mpn)}.pdf"
state.storage.download_to_local(lib_key, wanted)
if wanted.is_file():
return wanted
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↔periscopex 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"],
},
}
SHORTEST_PATH_SCHEMA = {
"name": "shortest_path",
"description": (
"Find the shortest hop path through the netlist between two pins "
"(component.pin → nets → components). Use to verify whether two "
"pins share a rail path, or how a signal reaches another IC, "
"instead of guessing from neighborhood context."
),
"input_schema": {
"type": "object",
"properties": {
"designator_a": {
"type": "string",
"description": "Start component reference, e.g. 'U1'",
},
"pin_a": {
"type": "string",
"description": "Start pin number, e.g. '12'",
},
"designator_b": {
"type": "string",
"description": "End component reference, e.g. 'U3'",
},
"pin_b": {
"type": "string",
"description": "End pin number, e.g. '5'",
},
},
"required": ["designator_a", "pin_a", "designator_b", "pin_b"],
},
}
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": (
"Required for ERROR and WARNING. Exact verbatim "
"datasheet text (max ~200 chars). Periscope "
"checks it against the PDF page. Omit only if "
"the evidence is a figure/scan with no 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,
SHORTEST_PATH_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 == "shortest_path":
return (
shortest_path(
graph, constraints_map,
tool_input["designator_a"],
tool_input["pin_a"],
tool_input["designator_b"],
tool_input["pin_b"],
),
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)