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:
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 {})
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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 10–100 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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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] = []
|
||||
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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, {})
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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.2k–10k 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.2–10 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.2–10 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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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"),
|
||||
)]
|
||||
@@ -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]
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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(),
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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 × (Vin−Vout); 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 (Vrail−Vf)/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
|
||||
@@ -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
@@ -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)
|
||||
Reference in New Issue
Block a user