Overlay leftover PinScope modules src still imported.
utils, resolve_passives, derating, bom_summary, pin_mux, LED current, pin-function tokens, plus live analysis validate/validation_tools/pipeline/ validation/extraction, skills, and taxonomy JSON now resolve from periscope/src. Inherited copies stay in dependency/. repo_paths prefers src then /app; Docker copies src taxonomy/skills last.
This commit is contained in:
@@ -2,6 +2,15 @@
|
||||
|
||||
What's new in Periscope.
|
||||
|
||||
## 2.43.0 — 2026-09-20 — Overlay leftover PinScope modules src still imported
|
||||
|
||||
`utils`, `resolve_passives`, `derating`, `bom_summary`, `pin_mux_check`, `led_current_check`, `pin_function_tokens`, plus live analysis `validate` / `validation_tools` / `pipeline` / `validation` / `extraction` (fallback), resolve from `periscope/src`. Skills (`extract-*`) and taxonomy JSON are copied to `periscope/src/{skills,taxonomy}`; Docker overlays them after `dependency/`. Inherited files stay on disk.
|
||||
|
||||
- [New] src overlay of the leftover `periscopex` helpers analysis/PCB still import.
|
||||
- [New] src overlay of `services/{pipeline,validation,extraction}.py` (re-exports `job_workspace` unchanged).
|
||||
- [New] `periscope/src/taxonomy/*.json` and `periscope/src/skills/extract-*`.
|
||||
- [Changed] `repo_paths` prefers src (then `/app`, then dependency) for taxonomy and skills.
|
||||
|
||||
## 2.42.0 — 2026-09-20 — Native graph/parsers/models/taxonomy overlay + Periscope mark
|
||||
|
||||
Graph builder, PADS/EDIF/BOM parsers, Pydantic models, and taxonomy loader resolve from `periscope/src`. Inherited copies stay in `periscope/dependency/` (not deleted). KiCad schematic parser was already native. Taxonomy JSON still lives in the inherited `taxonomy/` tree. New Periscope mark (periscope/lens, not the PinScope CPU glyph) at the existing favicon and PWA pixel sizes.
|
||||
|
||||
@@ -14,7 +14,9 @@ COPY periscope/dependency/backend/ /app/backend/
|
||||
COPY periscope/src/backend/ /app/backend/
|
||||
|
||||
COPY periscope/dependency/taxonomy/ /app/taxonomy/
|
||||
COPY periscope/src/taxonomy/ /app/taxonomy/
|
||||
COPY periscope/dependency/skills/ /app/skills/
|
||||
COPY periscope/src/skills/ /app/skills/
|
||||
COPY periscope/dependency/frontend/content/changelog.md /app/changelog.md
|
||||
COPY vendor/ /app/vendor/
|
||||
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Native Periscope overlay: BOM summary table.
|
||||
|
||||
PinScope original remains in dependency/.
|
||||
"""
|
||||
|
||||
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,202 @@
|
||||
"""Native Periscope overlay: capacitor voltage derating table.
|
||||
|
||||
PinScope original remains in dependency/.
|
||||
"""
|
||||
|
||||
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 _stress(op: float | None, rated: float | None) -> str:
|
||||
"""PASS / MARGIN / RISK from Vop vs Vrated. No invented dielectric %."""
|
||||
if op is None or rated is None or rated <= 0:
|
||||
return "UNKNOWN"
|
||||
ratio = op / rated
|
||||
if ratio > 1.0:
|
||||
return "RISK"
|
||||
if ratio > 0.8:
|
||||
return "MARGIN"
|
||||
return "PASS"
|
||||
|
||||
|
||||
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,
|
||||
"stress": _stress(op_voltage, rated_v),
|
||||
})
|
||||
|
||||
rows.sort(key=lambda r: natural_sort_key(r["designator"]))
|
||||
return rows
|
||||
@@ -0,0 +1,310 @@
|
||||
"""Native Periscope overlay: LED current check.
|
||||
|
||||
PinScope original remains in dependency/.
|
||||
"""
|
||||
|
||||
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,128 @@
|
||||
"""Native Periscope overlay: pin-function / net token parser.
|
||||
|
||||
PinScope original remains in dependency/.
|
||||
"""
|
||||
|
||||
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,164 @@
|
||||
"""Native Periscope overlay: pin-mux feasibility check.
|
||||
|
||||
PinScope original remains in dependency/.
|
||||
"""
|
||||
|
||||
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,579 @@
|
||||
"""Native Periscope overlay: passive MPN pattern resolver.
|
||||
|
||||
PinScope original remains in dependency/.
|
||||
"""
|
||||
|
||||
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,24 @@
|
||||
"""Native Periscope overlay: shared MPN/sort helpers.
|
||||
|
||||
PinScope original remains in dependency/.
|
||||
"""
|
||||
|
||||
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,939 @@
|
||||
"""Native Periscope overlay: inherited graph-query review tools.
|
||||
|
||||
Native review uses review_tools.py. PinScope original remains in dependency/.
|
||||
"""
|
||||
|
||||
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 on the board or schematic. "
|
||||
"Required for every finding, including INFO."
|
||||
),
|
||||
},
|
||||
"action": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Same as recommendation if you prefer that name. "
|
||||
"Required for every finding when recommendation is empty."
|
||||
),
|
||||
},
|
||||
},
|
||||
"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)
|
||||
@@ -22,6 +22,7 @@ def repo_root() -> Path:
|
||||
(p / "backend").is_dir()
|
||||
and (p / "taxonomy").is_dir()
|
||||
and (p / "skills").is_dir()
|
||||
and (p / "vendor").is_dir()
|
||||
and not (p / "periscope" / "src").is_dir()
|
||||
):
|
||||
return p
|
||||
@@ -47,10 +48,22 @@ def src_root() -> Path:
|
||||
|
||||
|
||||
def taxonomy_dir() -> Path:
|
||||
app = Path("/app/taxonomy")
|
||||
if app.is_dir() and any(app.glob("*.json")):
|
||||
return app
|
||||
src = src_root() / "taxonomy"
|
||||
if src.is_dir() and any(src.glob("*.json")):
|
||||
return src
|
||||
return dependency_root() / "taxonomy"
|
||||
|
||||
|
||||
def skills_dir() -> Path:
|
||||
app = Path("/app/skills")
|
||||
if app.is_dir() and (app / "extract-pintable" / "SKILL.md").is_file():
|
||||
return app
|
||||
src = src_root() / "skills"
|
||||
if src.is_dir() and (src / "extract-pintable" / "SKILL.md").is_file():
|
||||
return src
|
||||
return dependency_root() / "skills"
|
||||
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"default_model_version": "1.13.0",
|
||||
"extract-pintable": {
|
||||
"skill_id": "skill_01VMWPZuvuZAe4LmLbmsNWNY",
|
||||
"latest_version": "1784798970179642",
|
||||
"display_title": "Extract Pin Table"
|
||||
},
|
||||
"extract-pattern": {
|
||||
"skill_id": "skill_01JuA5xdSJsz2V4dcwzpTRpe",
|
||||
"latest_version": "1784798971057751",
|
||||
"display_title": "Extract Passive Pattern"
|
||||
},
|
||||
"extract-specs": {
|
||||
"skill_id": "skill_01NHZY6K3tvdbAzBo7eGT8qD",
|
||||
"latest_version": "1784798971971891",
|
||||
"display_title": "Extract Component Specs"
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
# Piano — indipendenza architettonica e di licenza da PinScope
|
||||
|
||||
**Stato:** split **2.38.0**. C2 review **2.39.x**. C3 extraction **2.40.0**. C4 PCB off `validate.py`. **2.41.0** native `job_workspace`. **2.42.0** native overlay: `graph` / `parsers` / `parsers_edif` / `models` / `taxonomy` in `periscope/src` (call sites unchanged; Docker/src-first). Originals **not** deleted in `dependency/`. KiCad sch parser already src. Taxonomy JSON still `dependency/taxonomy`. Fork non staccato.
|
||||
**Stato:** split **2.38.0**. C2–C4 as before. **2.41.0** job workspace. **2.42.0** graph/parsers/models/taxonomy overlay. **2.43.0** leftover helpers + live `pipeline`/`validation`/`extraction`/`validate` overlay; skills + taxonomy JSON copied to `periscope/src`. Originals **not** deleted. Fork non staccato.
|
||||
**Gate Michele:** sostituire/smettere di chiamare un modulo `dependency/` solo dopo pytest + deploy smoke. Se la verifica fallisce, resta il path ereditato.
|
||||
**Sequenza:** split → sostituzione incrementale (C2 loop → C3 extraction → C4 PCB off `validate.py` → C5 overlay graph/parsers/models/taxonomy). **Mai** empty-delete. Auto-place fuori scope. AGPL resta.
|
||||
**Sequenza:** split → sostituzione incrementale (C2…C5 overlay). **Mai** empty-delete. Auto-place fuori scope. AGPL resta.
|
||||
|
||||
Questo piano **non** stacca il fork GitHub (`manvalan/periscope` ← `Faradworks/Pinscope`). Lo stacco è un passo legale successivo, fuori da queste fasi di lavoro, salvo decisione esplicita.
|
||||
|
||||
@@ -31,9 +31,9 @@ Trattare come **una dipendenza in-tree**, non come prodotto Periscope:
|
||||
|
||||
| Pacchetto logico | Path **dopo lo split** | Licenza da audit |
|
||||
| --- | --- | --- |
|
||||
| Core schematico PinScope | `periscope/dependency/backend/periscopex/{graph,models,parsers,parsers_edif,validate,validation_tools,resolve_passives,derating,bom_summary,taxonomy,pin_mux_check,led_current_check,pin_function_tokens}.py` — **overlay 2.42.0** for graph/models/parsers/taxonomy in `periscope/src` (same names; keep inherited files) | AGPL-3.0 del fork (blob `LICENSE` identico a upstream) |
|
||||
| Orchestrazione review | `periscope/dependency/backend/services/pipeline.py`, `pipeline_worker.py`, `validation.py`, `extraction.py` (DIRECT) | stessa |
|
||||
| Skills Anthropic/Console | `periscope/dependency/skills/…`, `periscope/dependency/scripts/upload_skills.py`, `periscope/dependency/backend/skills_manifest.json` | stessa + contratto Claude |
|
||||
| Core schematico PinScope | `periscope/dependency/backend/periscopex/…` — **overlay 2.42.0–2.43.0** in `periscope/src` (same names; keep inherited files) | AGPL-3.0 del fork (blob `LICENSE` identico a upstream) |
|
||||
| Orchestrazione review | `pipeline_worker.py` still dependency-only; `pipeline.py` / `validation.py` / `extraction.py` **overlay 2.43.0** in src | stessa |
|
||||
| Skills Anthropic/Console | `periscope/dependency/skills/…` kept; **src copy 2.43.0** `periscope/src/skills/` + `src/backend/skills_manifest.json`. `upload_skills.py` still dependency | stessa + contratto Claude |
|
||||
| UI OSS / marketing shell | `periscope/dependency/frontend/` (UPSTREAM/DERIVED); file nativi in `periscope/src/frontend/` con symlink nel recinto | AGPL |
|
||||
| Gateway seams | `billing_hook.py`, `proxy.ts`, `clerk-theme-provider.tsx`, … sotto `periscope/dependency/` | stub open-core PinScope |
|
||||
| Fixture upstream | `periscope/dependency/simple_project/`, `periscope/dependency/docs/how-it-works.svg` | AGPL / contenuto upstream |
|
||||
@@ -62,6 +62,7 @@ Trattare come **una dipendenza in-tree**, non come prodotto Periscope:
|
||||
| Review loop C2 | `review_session.py`, `review_parse.py`, `review_tools.py`, `review_context.py`, `constraints_lookup.py` | REPLACEMENT 2.39.0; PinScope files kept |
|
||||
| Job workspace | `periscope/src/backend/services/job_workspace.py` | REPLACEMENT 2.41.0; PCB/placement off `pipeline.py` |
|
||||
| Graph / parsers / models / taxonomy | `periscope/src/backend/periscopex/{graph,parsers,parsers_edif,models,taxonomy}.py` | OVERLAY 2.42.0; inherited copies kept |
|
||||
| Leftover helpers + analysis overlay | `utils`, `resolve_passives`, `derating`, `bom_summary`, `pin_mux_check`, `led_current_check`, `pin_function_tokens`, `validate`, `validation_tools`, `services/{pipeline,validation,extraction}.py`, `src/taxonomy`, `src/skills` | OVERLAY 2.43.0 |
|
||||
| Deploy | `scripts/update-periscope.sh`, `docker-compose.yml`, `periscope/src/backend/Dockerfile` | NEW (nomi `pinscope_*` ancora WEAK) |
|
||||
| Plugin KiCad | `periscope/src/plugins/kicad/` | NEW |
|
||||
| Check deterministici fork | `dnp_check`, `sequencing_check`, `layout_rules`, … under `periscope/src` | NEW ma **INDIRECT**: usano `models` / graph |
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
---
|
||||
skill_name: extract-pattern
|
||||
description: Extract passive component MPN pattern (resistor, capacitor, inductor) from a datasheet PDF. Returns structured data via the save_pattern tool.
|
||||
---
|
||||
|
||||
# Extract Passive Component Pattern
|
||||
|
||||
Extract the part numbering system from a passive component datasheet (resistor, capacitor, inductor) and return it as structured JSON via the `save_pattern` tool.
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Read the datasheet PDF
|
||||
|
||||
The datasheet PDF is provided in the user message. Focus on finding the **Part Numbering System**, **Ordering Information**, or **Explanation of Part No.** section — every passive component datasheet has one. This section shows:
|
||||
- A diagram or table breaking the MPN into positional fields
|
||||
- The meaning of each field position
|
||||
- Lookup tables mapping codes to values (sizes, tolerances, voltage ratings, etc.)
|
||||
- An example part number with decoded fields
|
||||
|
||||
Also identify from the front page:
|
||||
- **Manufacturer name** (e.g., "Uniroyal", "Samsung Electro-Mechanics")
|
||||
- **Component type** — must be one of: `resistor`, `capacitor`, `inductor`
|
||||
- **Series/product name** (e.g., "Thick Film Chip Resistors", "CL Series MLCC")
|
||||
|
||||
### 2. Extract each field
|
||||
|
||||
For every field in the part number format, extract:
|
||||
- **name** — a short snake_case identifier matching the regex group name. Use these standard names where applicable:
|
||||
- `size` — package size code
|
||||
- `tolerance` — value tolerance
|
||||
- `resistance` — resistance value digits (for resistors)
|
||||
- `capacitance` — capacitance value digits (for capacitors)
|
||||
- `inductance` — inductance value digits (for inductors)
|
||||
- `voltage` — rated voltage
|
||||
- `wattage` — power rating (resistors)
|
||||
- `dielectric` — temperature characteristic / dielectric type (capacitors)
|
||||
- `packing_type` — tape/reel vs bulk
|
||||
- `packing_qty` — quantity per reel
|
||||
- `series` — product series prefix
|
||||
- `special` — special features
|
||||
- `thickness` — component thickness
|
||||
- `reserved` — reserved/unused codes
|
||||
- **position** — 0-based character offset in the MPN string
|
||||
- **length** — number of characters
|
||||
- **description** — human-readable description from the datasheet
|
||||
- **lookup** — complete mapping of code -> meaning extracted from the datasheet. For the primary value field (resistance/capacitance/inductance), leave lookup as `{}` since it's decoded algorithmically.
|
||||
|
||||
### 3. Determine the value decoder
|
||||
|
||||
Based on the component type and how the value field works, select the decoder type:
|
||||
|
||||
**For capacitors** using 3-digit EIA code in picofarads (e.g., "106" = 10x10^6 pF = 10uF):
|
||||
```json
|
||||
{
|
||||
"type": "eia3_pf",
|
||||
"base_unit": "pF",
|
||||
"output_unit": "F",
|
||||
"letter_multipliers": {},
|
||||
"zero_code": null,
|
||||
"conditional_on": null
|
||||
}
|
||||
```
|
||||
|
||||
**For resistors** using 4-digit code where the digit layout depends on tolerance:
|
||||
```json
|
||||
{
|
||||
"type": "eia4_ohm_conditional",
|
||||
"base_unit": "ohm",
|
||||
"output_unit": "ohm",
|
||||
"letter_multipliers": {"J": -1, "K": -2, "L": -3, "M": -4, "N": -5, "P": -6},
|
||||
"zero_code": "0000",
|
||||
"conditional_on": {
|
||||
"field": "tolerance",
|
||||
"high_tolerance": ["J"],
|
||||
"high_tolerance_layout": {
|
||||
"significant_start": 1,
|
||||
"significant_count": 2,
|
||||
"multiplier_index": 3
|
||||
},
|
||||
"low_tolerance_layout": {
|
||||
"significant_start": 0,
|
||||
"significant_count": 3,
|
||||
"multiplier_index": 3
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Read the datasheet carefully for:
|
||||
- Which tolerance codes use 3 vs 2 significant digits (the `high_tolerance` list)
|
||||
- Whether letter multiplier codes are supported (J, K, L, etc.) and their exponent values
|
||||
- Whether there's a special zero/jumper code
|
||||
|
||||
If the datasheet describes a different encoding scheme, adapt the decoder accordingly.
|
||||
|
||||
### 4. Build the regex pattern
|
||||
|
||||
Build a Python regex with named capture groups, one per field. The regex must:
|
||||
- Start with `^` and end with `$` (full MPN match)
|
||||
- Use `(?P<name>...)` syntax for each field
|
||||
- Be as specific as possible — enumerate known codes in alternation groups (e.g., `(?P<size>0603|0805|1206)`) rather than broad patterns like `\d{4}`
|
||||
- Handle the value field with appropriate character classes (digits + any letter multiplier codes)
|
||||
|
||||
### 5. Assign component subtype (taxonomy)
|
||||
|
||||
The existing passive taxonomy subtypes are provided in the system prompt under `EXISTING PASSIVE TAXONOMY SUBTYPES`. Pick the most specific matching subtype.
|
||||
|
||||
If no existing subtype fits, propose a new one following the dot-notation convention (`passive.{type}.{specific}`).
|
||||
|
||||
### 6. Quality checks
|
||||
|
||||
Before producing output, verify:
|
||||
- The regex matches ALL example MPNs (provided in the system prompt as BOM MPNs)
|
||||
- Every field has position + length that sum correctly across the full MPN
|
||||
- No field positions overlap
|
||||
- The primary value field (resistance/capacitance) has an empty `lookup` dict (it's decoded algorithmically)
|
||||
- All other fields have non-empty lookup dicts with codes extracted from the datasheet
|
||||
- The value decoder type is appropriate for the component type
|
||||
|
||||
### 7. Validate and output
|
||||
|
||||
Validate your extraction against the output schema:
|
||||
|
||||
```bash
|
||||
python3 /skills/extract-pattern/validate.py '<your JSON here>'
|
||||
```
|
||||
|
||||
If validation passes, call the `save_pattern` tool with the structured result.
|
||||
Do NOT write files to disk — use the tool.
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"manufacturer": {"type": "string"},
|
||||
"series": {"type": "string"},
|
||||
"component_type": {
|
||||
"type": "string",
|
||||
"enum": ["resistor", "capacitor", "inductor"]
|
||||
},
|
||||
"component_subtype": {"type": "string"},
|
||||
"description": {"type": "string"},
|
||||
"regex": {"type": "string"},
|
||||
"fields": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string"},
|
||||
"position": {"type": "integer"},
|
||||
"length": {"type": "integer"},
|
||||
"description": {"type": "string"},
|
||||
"lookup": {"type": "object"}
|
||||
},
|
||||
"required": ["name", "position", "length", "description"]
|
||||
}
|
||||
},
|
||||
"value_decoder": {"type": "object"},
|
||||
"example_mpns": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"manufacturer", "series", "component_type", "component_subtype",
|
||||
"description", "regex", "fields", "value_decoder", "example_mpns"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate extraction output against the pattern schema."""
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SCHEMA_PATH = Path(__file__).parent / "schema.json"
|
||||
|
||||
|
||||
def validate(data: dict) -> list[str]:
|
||||
"""Return list of validation errors (empty = valid)."""
|
||||
errors = []
|
||||
schema = json.loads(SCHEMA_PATH.read_text())
|
||||
|
||||
for field in schema.get("required", []):
|
||||
if field not in data:
|
||||
errors.append(f"Missing required field: {field}")
|
||||
|
||||
if "component_type" in data:
|
||||
ct = data["component_type"]
|
||||
if ct not in ("resistor", "capacitor", "inductor"):
|
||||
errors.append(f"component_type must be resistor/capacitor/inductor, got: {ct!r}")
|
||||
|
||||
if "regex" in data:
|
||||
try:
|
||||
pattern = re.compile(data["regex"])
|
||||
except re.error as e:
|
||||
errors.append(f"Invalid regex: {e}")
|
||||
pattern = None
|
||||
|
||||
if pattern and "example_mpns" in data:
|
||||
for mpn in data["example_mpns"]:
|
||||
if not pattern.match(mpn):
|
||||
errors.append(f"Regex does not match example MPN: {mpn!r}")
|
||||
|
||||
if "fields" in data:
|
||||
fields = data["fields"]
|
||||
if not isinstance(fields, list) or len(fields) == 0:
|
||||
errors.append("fields must be a non-empty array")
|
||||
else:
|
||||
for i, field in enumerate(fields):
|
||||
for f in ["name", "position", "length", "description"]:
|
||||
if f not in field:
|
||||
errors.append(f"fields[{i}] missing: {f}")
|
||||
|
||||
if "value_decoder" in data:
|
||||
vd = data["value_decoder"]
|
||||
if not isinstance(vd, dict) or "type" not in vd:
|
||||
errors.append("value_decoder must be an object with a 'type' field")
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: python3 validate.py '<json string>'")
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
data = json.loads(sys.argv[1])
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"INVALID JSON: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
errors = validate(data)
|
||||
if errors:
|
||||
print("VALIDATION FAILED:")
|
||||
for err in errors:
|
||||
print(f" - {err}")
|
||||
sys.exit(1)
|
||||
else:
|
||||
print("VALIDATION PASSED")
|
||||
@@ -0,0 +1,182 @@
|
||||
---
|
||||
skill_name: extract-pintable
|
||||
description: Extract pin table, package info, absolute-maximum ratings, layout_rules, and component subtype from an IC datasheet PDF. Returns structured data via the save_pintable tool.
|
||||
---
|
||||
|
||||
# Extract Pin Table & Variant Info
|
||||
|
||||
Extract structured data from an IC datasheet and return it via the `save_pintable` tool.
|
||||
|
||||
**Priority order:** (1) complete pin table for the MPN package, (2) `layout_rules` from PCB / typical-application pages, (3) package + abs-max + subtype.
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Read the datasheet PDF
|
||||
|
||||
Focus on these sections (figures count as evidence):
|
||||
- **Pin configuration / pin assignment table** — primary target
|
||||
- **Ordering information / part number decoder**
|
||||
- **Package information**
|
||||
- **PCB layout / layout guidelines / land pattern notes**
|
||||
- **Typical application / reference design** (placement callouts near caps, vias, keepouts)
|
||||
- **Absolute maximum ratings**
|
||||
|
||||
### 2. Extract the pin table
|
||||
|
||||
For every pin:
|
||||
- `number` (int or str) — pin number, or BGA ball like `"A3"`
|
||||
- `name` (str) — verbatim from the datasheet (e.g. `"VDD"`, `"PA0/SPI0_CLK"`)
|
||||
- `description` (str or null)
|
||||
- `functions` (list[str] or null) — alternate/mux functions
|
||||
|
||||
Rules:
|
||||
- Include ALL pins — power, ground, NC, exposed pad / EP
|
||||
- Names verbatim — do not rename or normalize
|
||||
- Multiplexed pins: primary in `name`, alternates in `functions`
|
||||
- If the datasheet has per-package tables, use the package matching the MPN
|
||||
- Off-by-one pin numbers break everything downstream — double-check
|
||||
|
||||
**Modules vs bare die (critical).** MPNs containing `WROOM`, `WROVER`, `MODULE`, `MOD-`, or `SIP` are *modules*. Extract the **module landing-pad table** (schematic pins). Do **not** extract the SoC/QFN ball map from a nested chip chapter.
|
||||
- Espressif WROOM: pin 1 is GND. Pin 1 named `ANT`, `CHIP_PU`, or `XTAL_*` means you grabbed the die table — invalid.
|
||||
- Crystal, RF antenna, and flash on a WROOM module are **inside the can**; they must not appear as schematic pin numbers.
|
||||
|
||||
Optional extras (omit if absent):
|
||||
- `internal_features.pullup_pins` / `esd_clamp_pins` / `analog_switch` from the **block diagram** only.
|
||||
|
||||
### 3. Extract layout_rules (required scan — empty OK)
|
||||
|
||||
You **must** look for layout guidance. Emit `layout_rules` as a list. Use `[]` only after scanning layout / application / thermal pages and finding no placement guidance.
|
||||
|
||||
#### Where to look
|
||||
- Headings: “PCB Layout”, “Layout Guidelines”, “Layout Considerations”, “Board Layout”, “Land Pattern”
|
||||
- “Typical Application”, “Application Circuit”, “Reference Design”
|
||||
- Thermal / EP / exposed-pad via recommendations
|
||||
- Callouts on application figures (“place CIN within 2 mm of VIN”)
|
||||
|
||||
#### Allowed `kind` (closed set)
|
||||
| kind | Use when |
|
||||
| --- | --- |
|
||||
| `decoupling_proximity` | Bypass / decoupling / input / output cap near a supply or pin |
|
||||
| `thermal_via` | Vias under exposed pad / thermal pad / EP |
|
||||
| `keepout` | Keep foreign nets, digital return, or copper out of a region |
|
||||
| `length_match` | Intra-pair skew / matched length limit in mm |
|
||||
| `impedance` | Single-ended `z0_ohm` or differential `zdiff_ohm` (plus `tolerance_pct` or `z_min_ohm`/`z_max_ohm`) |
|
||||
| `max_length` | Maximum routed length in mm |
|
||||
| `spacing` | Intra-pair / coupling gap (`min_spacing_mm`) |
|
||||
| `ref_plane` | Required reference plane (`ref_plane`, `topology`) |
|
||||
| `si_via` | Min/max vias on the HS net |
|
||||
| `layer` | Required copper layer / topology |
|
||||
| `series_resistor` | Series R on the HS net (`value_ohms`) |
|
||||
| `return_path` | GND return via next to the pair |
|
||||
| `emi` / `common_mode` / `shield` | Common-mode choke, ferrite bead, shield, or EMI filter **quoted from this datasheet** (no IEC 61000 invention) |
|
||||
|
||||
Do **not** emit impedance/50 Ω rules for I2C, GPIO, EN, analog REGN, or USB CC. Do **not** invent USB 90 Ω unless **this** datasheet states a number.
|
||||
|
||||
`net_class` is **required** for every SI kind (`impedance`, `length_match`, `max_length`, `spacing`, `ref_plane`, `si_via`, `layer`, `series_resistor`, `return_path`, `si`). Use one of: `usb2`, `usb3`, `eth_mdi`, `rgmii`, `sgmii`, `ddr3`, `hdmi`, `pcie`, `lvds`. PCB review will not map a rule onto another bus.
|
||||
|
||||
`series_resistor` is a termination / series R **on that HS net** (e.g. USB 22 Ω, RGMII 22 Ω). It is **not** CHIP_PU / EN / RESET RC (10 kΩ + 1 µF), ILIM, or a strap divider — omit those or use `decoupling_proximity` / leave them to timing checks.
|
||||
|
||||
#### Fields
|
||||
- `pin` — number or name as printed (`"5"`, `"VIN"`, `"VDD"`, `"EP"`)
|
||||
- `cap_value_hint` — only if shown (`"100nF"`, `"10µF"`)
|
||||
- `max_distance_mm` — **number only if the PDF states millimetres**
|
||||
- OK: “within 2 mm”, “< 5 mm”, “no more than 3 mm from the pin” → `2` / `5` / `3`
|
||||
- NOT OK as a number: “as close as possible”, “close to the pin”, “adjacent”, “nearby” → set `max_distance_mm: null` and keep the rule with a `note`
|
||||
- **Never invent** JEDEC, USB, IPC, or “standard 3 mm / 5 mm” distances
|
||||
- `same_layer` — `true`/`false` only if text says same side / opposite side of the board; else null
|
||||
- `min_via_count` — integer only if stated (“at least 4 vias”)
|
||||
- `net_class` — **required for SI kinds**: `usb2` | `usb3` | `eth_mdi` | `rgmii` | `sgmii` | `ddr3` | `hdmi` | `pcie` | `lvds`. Must match the quoted bus (PHY+RJ45 = `eth_mdi`, MAC–PHY = `rgmii`/`sgmii`, USB-C SuperSpeed = `usb3`, USB D+/D− = `usb2`). Never leave SI `net_class` empty.
|
||||
- `note` — short quote of the guidance
|
||||
- `source_page` — 1-based page of the guidance (required when you emit a rule)
|
||||
|
||||
#### Examples
|
||||
|
||||
Numeric proximity (copy the millimetre from the PDF):
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": "decoupling_proximity",
|
||||
"pin": "VIN",
|
||||
"cap_value_hint": "10uF",
|
||||
"max_distance_mm": 2.0,
|
||||
"same_layer": true,
|
||||
"note": "Place CIN within 2 mm of VIN",
|
||||
"source_page": 14
|
||||
}
|
||||
```
|
||||
|
||||
Proximity without a millimetre (still emit the rule):
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": "decoupling_proximity",
|
||||
"pin": "VDD",
|
||||
"cap_value_hint": "100nF",
|
||||
"max_distance_mm": null,
|
||||
"note": "Place decoupling capacitor as close as possible to VDD",
|
||||
"source_page": 22
|
||||
}
|
||||
```
|
||||
|
||||
Thermal vias:
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": "thermal_via",
|
||||
"pin": "EP",
|
||||
"min_via_count": 4,
|
||||
"note": "Use at least 4 thermal vias in the exposed pad",
|
||||
"source_page": 18
|
||||
}
|
||||
```
|
||||
|
||||
#### Hard negatives
|
||||
- Do not invent land-pattern pad sizes from the mechanical drawing alone
|
||||
- Do not emit `length_match` or `impedance` for USB/HDMI/PCIe unless **this** datasheet states a skew/Z number
|
||||
- Do not treat I2C, GPIO, EN, analog, or USB-CC as 50 Ω / 90 Ω pairs
|
||||
- Do not emit `series_resistor` for EN / CHIP_PU / RESET RC, ILIM, or strap networks
|
||||
- Do not emit an SI kind without `net_class` naming the quoted bus
|
||||
- Do not use kinds outside the closed set
|
||||
- One rule per distinct pin/guidance; prefer supply pins that show caps in the application figure
|
||||
|
||||
### 4. Extract package info
|
||||
|
||||
- `base_family` — e.g. `"MSPM0G3507"` from `"MSPM0G3507SPTR"`
|
||||
- `package` — e.g. `"LQFP-48"`, `"SOT-23-5"`
|
||||
- `pin_count` (int)
|
||||
- `description` — human-readable MPN decode
|
||||
|
||||
Prefer “Ordering Information” / “Device Information” tables.
|
||||
|
||||
### 5. Extract absolute maximum ratings
|
||||
|
||||
Copy the **Absolute Maximum Ratings** table (not Recommended Operating Conditions):
|
||||
|
||||
- `parameter`, `min` / `max`, `unit`, `source_page` (1-based)
|
||||
|
||||
Include supply voltages, pin/input voltages, input current, temperature. Skip HBM/IEC kV ESD rows unless they are the only voltage limit. Do not invent numbers.
|
||||
|
||||
**ESD / TVS (`ic.protection.esd` and similar):** also from Electrical Characteristics:
|
||||
- Vrwm / operating voltage as signed min/max in volts
|
||||
- One row for polarity/topology as printed (`bidirectional`, …), `unit: "—"`
|
||||
|
||||
### 6. Assign component subtype
|
||||
|
||||
Pick the best dotted subtype from `EXISTING IC TAXONOMY SUBTYPES` (e.g. `ic.mcu`, `ic.power.ldo`). If none fit, propose `ic.{category}.{specific}`.
|
||||
|
||||
### 7. Quality checks
|
||||
|
||||
Before output:
|
||||
- Pin count matches the package for this MPN
|
||||
- No duplicate / missing pin numbers
|
||||
- `layout_rules` scanned (list present; `[]` only if truly no guidance)
|
||||
- Every emitted rule has a valid `kind`; every numeric `max_distance_mm` comes from the PDF text/figure
|
||||
- Pin names are not OCR garbage
|
||||
|
||||
### 8. Validate and output
|
||||
|
||||
```bash
|
||||
python3 /skills/extract-pintable/validate.py '<your JSON here>'
|
||||
```
|
||||
|
||||
If validation passes, call `save_pintable`. Do NOT write files to disk — use the tool.
|
||||
@@ -0,0 +1,109 @@
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"component_subtype": {
|
||||
"type": "string",
|
||||
"description": "Dotted taxonomy path, e.g. ic.mcu, ic.power.ldo"
|
||||
},
|
||||
"package_info": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"base_family": {"type": "string"},
|
||||
"package": {"type": "string"},
|
||||
"pin_count": {"type": "integer"},
|
||||
"description": {"type": "string"}
|
||||
},
|
||||
"required": ["base_family", "package", "pin_count"]
|
||||
},
|
||||
"pintable": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"number": {},
|
||||
"name": {"type": "string"},
|
||||
"description": {"type": "string"},
|
||||
"functions": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"}
|
||||
}
|
||||
},
|
||||
"required": ["number", "name"]
|
||||
}
|
||||
},
|
||||
"absolute_maximum_ratings": {
|
||||
"type": "array",
|
||||
"description": "Abs-max rows, plus Vrwm and polarity/topology for ESD/TVS ICs.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"parameter": {"type": "string"},
|
||||
"min": {"type": ["number", "null"]},
|
||||
"max": {"type": ["number", "null"]},
|
||||
"unit": {"type": "string"},
|
||||
"source_page": {"type": "integer"}
|
||||
},
|
||||
"required": ["parameter", "unit", "source_page"]
|
||||
}
|
||||
},
|
||||
"internal_features": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"esd_clamp_pins": {"type": "array", "items": {"type": "string"}},
|
||||
"pullup_pins": {"type": "array", "items": {"type": "string"}},
|
||||
"analog_switch": {"type": "array", "items": {"type": "string"}}
|
||||
}
|
||||
},
|
||||
"layout_rules": {
|
||||
"type": "array",
|
||||
"description": "PCB layout constraints from typical-application / PCB layout pages. Empty if none stated.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"decoupling_proximity",
|
||||
"thermal_via",
|
||||
"keepout",
|
||||
"length_match",
|
||||
"impedance",
|
||||
"max_length",
|
||||
"spacing",
|
||||
"ref_plane",
|
||||
"si_via",
|
||||
"layer",
|
||||
"series_resistor",
|
||||
"return_path",
|
||||
"si",
|
||||
"emi",
|
||||
"common_mode",
|
||||
"shield"
|
||||
]
|
||||
},
|
||||
"pin": {"type": ["string", "null"]},
|
||||
"cap_value_hint": {"type": ["string", "null"]},
|
||||
"max_distance_mm": {"type": ["number", "null"]},
|
||||
"same_layer": {"type": ["boolean", "null"]},
|
||||
"min_via_count": {"type": ["integer", "null"]},
|
||||
"max_via_count": {"type": ["integer", "null"]},
|
||||
"net_class": {"type": ["string", "null"]},
|
||||
"note": {"type": ["string", "null"]},
|
||||
"source_page": {"type": ["integer", "null"]},
|
||||
"z0_ohm": {"type": ["number", "null"]},
|
||||
"zdiff_ohm": {"type": ["number", "null"]},
|
||||
"tolerance_pct": {"type": ["number", "null"]},
|
||||
"z_min_ohm": {"type": ["number", "null"]},
|
||||
"z_max_ohm": {"type": ["number", "null"]},
|
||||
"topology": {"type": ["string", "null"]},
|
||||
"min_spacing_mm": {"type": ["number", "null"]},
|
||||
"value_ohms": {"type": ["number", "null"]},
|
||||
"ref_plane": {"type": ["string", "null"]},
|
||||
"parameter": {"type": ["string", "null"]}
|
||||
},
|
||||
"required": ["kind"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["component_subtype", "package_info", "pintable"]
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate extraction output against the pintable schema."""
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SCHEMA_PATH = Path(__file__).parent / "schema.json"
|
||||
|
||||
|
||||
def validate(data: dict) -> list[str]:
|
||||
"""Return list of validation errors (empty = valid)."""
|
||||
errors = []
|
||||
schema = json.loads(SCHEMA_PATH.read_text())
|
||||
|
||||
for field in schema.get("required", []):
|
||||
if field not in data:
|
||||
errors.append(f"Missing required field: {field}")
|
||||
|
||||
if "component_subtype" in data:
|
||||
st = data["component_subtype"]
|
||||
if not isinstance(st, str) or "." not in st:
|
||||
errors.append(f"component_subtype must be dotted path, got: {st!r}")
|
||||
|
||||
if "package_info" in data:
|
||||
pkg = data["package_info"]
|
||||
for f in ["base_family", "package", "pin_count"]:
|
||||
if f not in pkg:
|
||||
errors.append(f"package_info missing required field: {f}")
|
||||
if "pin_count" in pkg and not isinstance(pkg["pin_count"], int):
|
||||
errors.append(f"package_info.pin_count must be integer, got: {type(pkg['pin_count']).__name__}")
|
||||
|
||||
if "pintable" in data:
|
||||
pins = data["pintable"]
|
||||
if not isinstance(pins, list) or len(pins) == 0:
|
||||
errors.append("pintable must be a non-empty array")
|
||||
else:
|
||||
numbers = []
|
||||
for i, pin in enumerate(pins):
|
||||
if "number" not in pin:
|
||||
errors.append(f"pintable[{i}] missing required field: number")
|
||||
if "name" not in pin:
|
||||
errors.append(f"pintable[{i}] missing required field: name")
|
||||
if "number" in pin:
|
||||
numbers.append(pin["number"])
|
||||
dupes = [n for n in set(numbers) if numbers.count(n) > 1]
|
||||
if dupes:
|
||||
errors.append(f"Duplicate pin numbers: {dupes}")
|
||||
|
||||
names = {
|
||||
str(p.get("number")): str(p.get("name") or "").upper()
|
||||
for p in pins if "number" in p
|
||||
}
|
||||
pin1 = names.get("1", "")
|
||||
looks_like_rf_die = bool(
|
||||
re.search(r"\bANT\b|^CHIP_PU$|^XTAL", pin1)
|
||||
and any("XTAL" in n for n in names.values())
|
||||
)
|
||||
mpn = str(data.get("mpn") or "")
|
||||
is_module_mpn = bool(re.search(r"WROOM|WROVER|\bMODULE\b|\bSIP\b", mpn, re.I))
|
||||
if looks_like_rf_die and is_module_mpn:
|
||||
errors.append(
|
||||
"Pin 1 looks like a bare RF SoC ball (ANT/CHIP_PU) with XTAL "
|
||||
"pins in the table. Module footprints (WROOM) use pad 1 = GND; "
|
||||
"extract the module landing-pad table, not the die map."
|
||||
)
|
||||
|
||||
if "absolute_maximum_ratings" in data:
|
||||
ratings = data["absolute_maximum_ratings"]
|
||||
if ratings is not None and not isinstance(ratings, list):
|
||||
errors.append("absolute_maximum_ratings must be an array")
|
||||
elif isinstance(ratings, list):
|
||||
for i, row in enumerate(ratings):
|
||||
if not isinstance(row, dict):
|
||||
errors.append(f"absolute_maximum_ratings[{i}] must be an object")
|
||||
continue
|
||||
for f in ("parameter", "unit", "source_page"):
|
||||
if f not in row:
|
||||
errors.append(
|
||||
f"absolute_maximum_ratings[{i}] missing required field: {f}"
|
||||
)
|
||||
|
||||
if "layout_rules" in data and data["layout_rules"] is not None:
|
||||
if not isinstance(data["layout_rules"], list):
|
||||
errors.append("layout_rules must be an array")
|
||||
else:
|
||||
kinds = {
|
||||
"decoupling_proximity", "thermal_via", "keepout", "length_match",
|
||||
"impedance", "max_length", "spacing", "ref_plane", "si_via",
|
||||
"layer", "series_resistor", "return_path", "si",
|
||||
"emi", "common_mode", "shield",
|
||||
}
|
||||
for i, row in enumerate(data["layout_rules"]):
|
||||
if not isinstance(row, dict):
|
||||
errors.append(f"layout_rules[{i}] must be an object")
|
||||
continue
|
||||
kind = row.get("kind")
|
||||
if kind not in kinds:
|
||||
errors.append(f"layout_rules[{i}] unknown kind: {kind!r}")
|
||||
continue
|
||||
dist = row.get("max_distance_mm")
|
||||
if dist is not None and dist is not False:
|
||||
if isinstance(dist, bool):
|
||||
errors.append(
|
||||
f"layout_rules[{i}].max_distance_mm must be a number or null"
|
||||
)
|
||||
elif isinstance(dist, (int, float)):
|
||||
if float(dist) <= 0:
|
||||
errors.append(
|
||||
f"layout_rules[{i}].max_distance_mm must be > 0"
|
||||
)
|
||||
else:
|
||||
try:
|
||||
v = float(str(dist).strip())
|
||||
except (TypeError, ValueError):
|
||||
errors.append(
|
||||
f"layout_rules[{i}].max_distance_mm must be numeric "
|
||||
f"or null (got {dist!r}) — do not invent distances; "
|
||||
f"use null when the PDF only says 'close'"
|
||||
)
|
||||
else:
|
||||
if v <= 0:
|
||||
errors.append(
|
||||
f"layout_rules[{i}].max_distance_mm must be > 0"
|
||||
)
|
||||
via = row.get("min_via_count")
|
||||
if via is not None and via is not False and not isinstance(via, bool):
|
||||
if isinstance(via, int):
|
||||
if via <= 0:
|
||||
errors.append(
|
||||
f"layout_rules[{i}].min_via_count must be > 0"
|
||||
)
|
||||
else:
|
||||
try:
|
||||
iv = int(float(str(via).strip()))
|
||||
except (TypeError, ValueError):
|
||||
errors.append(
|
||||
f"layout_rules[{i}].min_via_count must be an integer "
|
||||
f"or null (got {via!r})"
|
||||
)
|
||||
else:
|
||||
if iv <= 0:
|
||||
errors.append(
|
||||
f"layout_rules[{i}].min_via_count must be > 0"
|
||||
)
|
||||
page = row.get("source_page")
|
||||
if page is not None and not isinstance(page, int):
|
||||
errors.append(
|
||||
f"layout_rules[{i}].source_page must be an integer or null"
|
||||
)
|
||||
same = row.get("same_layer")
|
||||
if same is not None and not isinstance(same, bool):
|
||||
errors.append(
|
||||
f"layout_rules[{i}].same_layer must be a boolean or null"
|
||||
)
|
||||
si_kinds = {
|
||||
"length_match", "impedance", "max_length", "spacing",
|
||||
"ref_plane", "si_via", "layer", "series_resistor",
|
||||
"return_path", "si",
|
||||
}
|
||||
nc = row.get("net_class")
|
||||
if kind in si_kinds and not (isinstance(nc, str) and nc.strip()):
|
||||
errors.append(
|
||||
f"layout_rules[{i}] SI kind {kind!r} requires net_class "
|
||||
f"(usb2|usb3|eth_mdi|rgmii|sgmii|ddr3|hdmi|pcie|lvds)"
|
||||
)
|
||||
note = str(row.get("note") or "")
|
||||
pin = str(row.get("pin") or "")
|
||||
if kind == "series_resistor" and (
|
||||
re.search(r"[µu]F", note, re.I)
|
||||
or re.search(r"\b(EN|CHIP_PU|CHIP_EN|STRAP|ILIM)\b", f"{note} {pin}", re.I)
|
||||
):
|
||||
errors.append(
|
||||
f"layout_rules[{i}] series_resistor is HS termination, "
|
||||
f"not EN/CHIP_PU RC or strap"
|
||||
)
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: python3 validate.py '<json string>'")
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
data = json.loads(sys.argv[1])
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"INVALID JSON: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
errors = validate(data)
|
||||
if errors:
|
||||
print("VALIDATION FAILED:")
|
||||
for err in errors:
|
||||
print(f" - {err}")
|
||||
sys.exit(1)
|
||||
else:
|
||||
print("VALIDATION PASSED")
|
||||
@@ -0,0 +1,76 @@
|
||||
---
|
||||
skill_name: extract-specs
|
||||
description: Extract pin table, package info, and electrical specifications from a discrete/simple component datasheet PDF. Returns structured data via the save_specs tool.
|
||||
---
|
||||
|
||||
# Extract Component Specifications & Pin Table
|
||||
|
||||
Extract the pin table, package info, and key electrical specifications from a component datasheet and return them as structured JSON via the `save_specs` tool.
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Read the datasheet PDF
|
||||
|
||||
The datasheet PDF is provided in the user message. Focus on these sections:
|
||||
- **Pin configuration / pin assignment table** — Pin number, pin name, description
|
||||
- **Package information** — Pin count, package type
|
||||
- **Electrical characteristics** — The primary source of parameter values
|
||||
- **Absolute maximum ratings** — Maximum voltage, current, and power limits
|
||||
|
||||
### 2. Identify the component subtype
|
||||
|
||||
The system prompt provides a list of taxonomy subtypes. Choose the best match for this component. If none match, propose a new subtype following the dotted naming convention.
|
||||
|
||||
### 3. Extract the pin table
|
||||
|
||||
For every pin on the component, extract:
|
||||
- `number` (int or str) — The pin number as printed in the datasheet
|
||||
- `name` (str) — The pin name exactly as printed (e.g., `"A"` for anode, `"K"` for cathode, `"G"` for gate)
|
||||
- `description` (str or null) — A brief description if the datasheet provides one
|
||||
- `functions` (list[str] or null) — Alternate functions if the pin supports them
|
||||
|
||||
Rules for pin extraction:
|
||||
- Include ALL pins — including pad/tab/exposed pad pins
|
||||
- Use pin names verbatim from the datasheet — do not rename or normalize
|
||||
- Pay careful attention to pin numbering — off-by-one errors break downstream validation
|
||||
- For multi-pin packages (e.g., SOT-23 transistor), ensure the pin assignment matches the specific package variant
|
||||
|
||||
### 4. Extract package info
|
||||
|
||||
Decode the MPN and package details:
|
||||
- `base_family` (str) — The base part family (e.g., `"BAT54"` from `"BAT54S"`)
|
||||
- `package` (str) — Package name (e.g., `"SOT-23"`, `"SOD-123"`, `"TO-220"`)
|
||||
- `pin_count` (int) — Number of pins
|
||||
- `description` (str) — Human-readable decoding of the full MPN
|
||||
|
||||
### 5. Extract specifications
|
||||
|
||||
The system prompt contains a "PARAMETERS TO EXTRACT" section listing the **ONLY** parameters you should extract. These are the standardized parameters for this component type that are useful for schematic validation.
|
||||
|
||||
**CRITICAL: Extract ONLY the parameters listed in "PARAMETERS TO EXTRACT".** Do not add any other parameters, even if they appear in the datasheet. Parameters like contact material, insulator material, processing temperature, orientation, mounting type, plating, etc. are NOT useful for schematic validation and MUST be excluded.
|
||||
|
||||
For each listed parameter:
|
||||
|
||||
- **Search systematically**: Check electrical characteristics tables, absolute maximum ratings, and application notes
|
||||
- **Prefer typical operating values** where available, but note maximums for rating parameters
|
||||
- **Use SPICE multiplier prefixes** for all values with units: `T`=1e12, `G`=1e9, `M`=1e6, `k`=1e3, `m`=1e-3, `u`=1e-6, `n`=1e-9, `p`=1e-12. Pick the multiplier that gives the most readable number.
|
||||
- Good: `"30V"`, `"240mV"`, `"500mA"`, `"47mohm"`, `"18pF"`, `"8MHz"`, `"10nC"`
|
||||
- Bad: `"0.24V"`, `"0.5A"`, `"0.047ohm"`, `"0.000000000018F"`, `"8000000Hz"`
|
||||
- **Always include the unit** with the multiplier in the value string
|
||||
- **Use numeric values** only when the parameter is inherently unitless (e.g., turns ratio, pin count, hFE)
|
||||
- **Use null** for parameters that are not applicable to this component or not found in the datasheet
|
||||
|
||||
Rules:
|
||||
- Extract from the datasheet only — do not infer or calculate values
|
||||
- If a parameter has different values at different conditions, use the value at the most common/standard condition
|
||||
- For parameters with min/typ/max, prefer typical; include all in the string if they matter (e.g., `"550mV typ, 850mV max"`)
|
||||
- **ONLY use parameter names from the "PARAMETERS TO EXTRACT" list** — any extra keys will be discarded
|
||||
|
||||
### 6. Call save_specs
|
||||
|
||||
Call the `save_specs` tool with:
|
||||
- `component_subtype`: The dotted taxonomy path (e.g., `"discrete.diode.schottky"`)
|
||||
- `component_subtype_description`: A brief description if this is a new subtype
|
||||
- `package_info`: Package details (base_family, package, pin_count, description)
|
||||
- `pintable`: Array of pin objects (number, name, description, functions)
|
||||
- `values`: An object mapping parameter names to their extracted values
|
||||
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"component_subtype": {
|
||||
"type": "string",
|
||||
"description": "Dotted taxonomy path, e.g. discrete.diode.schottky, connector.usb",
|
||||
"pattern": "^[a-z][a-z0-9_]+(\\.[a-z][a-z0-9_]+)*$"
|
||||
},
|
||||
"component_subtype_description": {
|
||||
"type": "string",
|
||||
"description": "Brief description of the component subtype. Used when this is a new taxonomy entry."
|
||||
},
|
||||
"package_info": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"base_family": {"type": "string"},
|
||||
"package": {"type": "string"},
|
||||
"pin_count": {"type": "integer"},
|
||||
"description": {"type": "string"}
|
||||
},
|
||||
"required": ["base_family", "package", "pin_count"]
|
||||
},
|
||||
"pintable": {
|
||||
"type": "array",
|
||||
"description": "Pin table for the component. Include ALL pins.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"number": {},
|
||||
"name": {"type": "string"},
|
||||
"description": {"type": "string"},
|
||||
"functions": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"}
|
||||
}
|
||||
},
|
||||
"required": ["number", "name"]
|
||||
}
|
||||
},
|
||||
"values": {
|
||||
"type": "object",
|
||||
"description": "Extracted parameter values keyed ONLY by parameter names from the PARAMETERS TO EXTRACT list. Use SPICE multiplier prefixes (k, M, m, u, n, p) with units. Use null for missing/inapplicable parameters. Do NOT add parameters not in the list.",
|
||||
"additionalProperties": {
|
||||
"type": ["string", "number", "null"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["component_subtype", "component_subtype_description", "package_info", "pintable", "values"]
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate extraction output against the specs schema."""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SCHEMA_PATH = Path(__file__).parent / "schema.json"
|
||||
|
||||
|
||||
def validate(data: dict) -> list[str]:
|
||||
"""Return list of validation errors (empty = valid)."""
|
||||
errors = []
|
||||
schema = json.loads(SCHEMA_PATH.read_text())
|
||||
|
||||
for field in schema.get("required", []):
|
||||
if field not in data:
|
||||
errors.append(f"Missing required field: {field}")
|
||||
|
||||
if "component_subtype" in data:
|
||||
st = data["component_subtype"]
|
||||
if not isinstance(st, str) or "." not in st:
|
||||
errors.append(f"component_subtype must be dotted path, got: {st!r}")
|
||||
|
||||
if "package_info" in data:
|
||||
pkg = data["package_info"]
|
||||
for f in ["base_family", "package", "pin_count"]:
|
||||
if f not in pkg:
|
||||
errors.append(f"package_info missing required field: {f}")
|
||||
if "pin_count" in pkg and not isinstance(pkg["pin_count"], int):
|
||||
errors.append(f"package_info.pin_count must be integer, got: {type(pkg['pin_count']).__name__}")
|
||||
|
||||
if "pintable" in data:
|
||||
pins = data["pintable"]
|
||||
if not isinstance(pins, list) or len(pins) == 0:
|
||||
errors.append("pintable must be a non-empty array")
|
||||
else:
|
||||
numbers = []
|
||||
for i, pin in enumerate(pins):
|
||||
if "number" not in pin:
|
||||
errors.append(f"pintable[{i}] missing required field: number")
|
||||
if "name" not in pin:
|
||||
errors.append(f"pintable[{i}] missing required field: name")
|
||||
if "number" in pin:
|
||||
numbers.append(pin["number"])
|
||||
dupes = [n for n in set(numbers) if numbers.count(n) > 1]
|
||||
if dupes:
|
||||
errors.append(f"Duplicate pin numbers: {dupes}")
|
||||
|
||||
if "values" in data:
|
||||
values = data["values"]
|
||||
if not isinstance(values, dict):
|
||||
errors.append(f"values must be an object, got: {type(values).__name__}")
|
||||
else:
|
||||
for k, v in values.items():
|
||||
if v is not None and not isinstance(v, (str, int, float)):
|
||||
errors.append(f"values[{k!r}] must be string, number, or null, got: {type(v).__name__}")
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: python3 validate.py '<json string>'")
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
data = json.loads(sys.argv[1])
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"INVALID JSON: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
errors = validate(data)
|
||||
if errors:
|
||||
print("VALIDATION FAILED:")
|
||||
for err in errors:
|
||||
print(f" - {err}")
|
||||
sys.exit(1)
|
||||
else:
|
||||
print("VALIDATION PASSED")
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"type": "connector",
|
||||
"specs": [
|
||||
{"name": "pin_count", "description": "Number of pins/contacts", "required": true},
|
||||
{"name": "voltage_rating_v", "description": "Maximum rated voltage", "unit": "V"},
|
||||
{"name": "current_rating_a", "description": "Maximum current per contact", "unit": "A"}
|
||||
],
|
||||
"subtypes": {
|
||||
"connector.header": {
|
||||
"description": "Pin header connector",
|
||||
"extra_specs": [
|
||||
{"name": "pitch_mm", "description": "Pin pitch (center-to-center spacing)", "unit": "mm"},
|
||||
{"name": "rows", "description": "Number of rows"},
|
||||
{"name": "positions_per_row", "description": "Number of positions per row"}
|
||||
]
|
||||
},
|
||||
"connector.usb": {
|
||||
"description": "USB connector",
|
||||
"extra_specs": [
|
||||
{"name": "usb_standard", "description": "USB standard version (2.0, 3.0, 3.1, Type-C)"}
|
||||
]
|
||||
},
|
||||
"connector.fpc": {
|
||||
"description": "FPC/FFC connector",
|
||||
"extra_specs": [
|
||||
{"name": "pitch_mm", "description": "Contact pitch (center-to-center spacing)", "unit": "mm"}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"type": "crystal",
|
||||
"specs": [
|
||||
{"name": "frequency_hz", "description": "Nominal frequency", "unit": "Hz", "required": true},
|
||||
{"name": "load_capacitance_f", "description": "Specified load capacitance (CL)", "unit": "F"},
|
||||
{"name": "esr_ohm", "description": "Equivalent series resistance (ESR)", "unit": "ohm"}
|
||||
],
|
||||
"subtypes": {
|
||||
"crystal": {
|
||||
"description": "Crystal / crystal oscillator",
|
||||
"extra_specs": [
|
||||
{"name": "frequency_stability_ppm", "description": "Frequency stability/tolerance", "unit": "ppm"},
|
||||
{"name": "drive_level_w", "description": "Maximum drive level", "unit": "W"},
|
||||
{"name": "shunt_capacitance_f", "description": "Shunt capacitance (C0)", "unit": "F"}
|
||||
]
|
||||
},
|
||||
"crystal.crystal": {
|
||||
"description": "Crystal / crystal oscillator",
|
||||
"example_mpn": "ABM8-19.200MHZ-10-1-U-T"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
{
|
||||
"type": "discrete",
|
||||
"specs": [
|
||||
{"name": "package", "description": "Package type (e.g. SOD-123, SOT-23, TO-220)"},
|
||||
{"name": "power_dissipation_w", "description": "Maximum power dissipation", "unit": "W"}
|
||||
],
|
||||
"subtypes": {
|
||||
"discrete.diode.rectifier": {
|
||||
"description": "Standard rectifier diode",
|
||||
"extra_specs": [
|
||||
{"name": "reverse_voltage_v", "description": "Maximum reverse voltage (Vr/Vrrm)", "unit": "V", "required": true},
|
||||
{"name": "forward_voltage_v", "description": "Typical forward voltage drop (Vf)", "unit": "V"},
|
||||
{"name": "forward_current_a", "description": "Maximum continuous forward current (If)", "unit": "A"}
|
||||
]
|
||||
},
|
||||
"discrete.diode.schottky": {
|
||||
"description": "Schottky barrier diode",
|
||||
"extra_specs": [
|
||||
{"name": "reverse_voltage_v", "description": "Maximum reverse voltage (Vr)", "unit": "V", "required": true},
|
||||
{"name": "forward_voltage_v", "description": "Typical forward voltage drop (Vf)", "unit": "V"},
|
||||
{"name": "forward_current_a", "description": "Maximum continuous forward current (If)", "unit": "A"}
|
||||
]
|
||||
},
|
||||
"discrete.diode.zener": {
|
||||
"description": "Zener voltage regulator diode",
|
||||
"extra_specs": [
|
||||
{"name": "zener_voltage_v", "description": "Nominal Zener voltage (Vz)", "unit": "V", "required": true},
|
||||
{"name": "zener_impedance_ohm", "description": "Zener impedance (Zzt)", "unit": "ohm"}
|
||||
]
|
||||
},
|
||||
"discrete.diode.tvs": {
|
||||
"description": "TVS transient voltage suppressor diode",
|
||||
"extra_specs": [
|
||||
{"name": "standoff_voltage_v", "description": "Working standoff voltage (Vrwm)", "unit": "V", "required": true},
|
||||
{"name": "clamping_voltage_v", "description": "Clamping voltage at Ipp", "unit": "V"},
|
||||
{"name": "peak_pulse_current_a", "description": "Peak pulse current (Ipp)", "unit": "A"}
|
||||
]
|
||||
},
|
||||
"discrete.diode.esd": {
|
||||
"description": "ESD protection diode / array for data lines",
|
||||
"example_mpn": "USBLC6-2SC6",
|
||||
"extra_specs": [
|
||||
{"name": "standoff_voltage_v", "description": "Working standoff voltage (Vrwm)", "unit": "V", "required": true},
|
||||
{"name": "clamping_voltage_v", "description": "Clamping voltage at specified current", "unit": "V"},
|
||||
{"name": "io_capacitance_f", "description": "I/O line capacitance (Cio) — critical for signal integrity on data lines", "unit": "F"},
|
||||
{"name": "leakage_current_a", "description": "Reverse leakage current (IR)", "unit": "A"}
|
||||
]
|
||||
},
|
||||
"discrete.transistor.mosfet.n_channel": {
|
||||
"description": "N-channel MOSFET",
|
||||
"extra_specs": [
|
||||
{"name": "vds_max_v", "description": "Maximum drain-source voltage (Vds)", "unit": "V", "required": true},
|
||||
{"name": "id_max_a", "description": "Maximum continuous drain current (Id)", "unit": "A"},
|
||||
{"name": "rds_on_ohm", "description": "On-resistance (Rds_on)", "unit": "ohm"},
|
||||
{"name": "vgs_th_v", "description": "Gate threshold voltage (Vgs_th)", "unit": "V"},
|
||||
{"name": "qg_c", "description": "Total gate charge (Qg)", "unit": "C"}
|
||||
]
|
||||
},
|
||||
"discrete.transistor.mosfet.p_channel": {
|
||||
"description": "P-channel MOSFET",
|
||||
"extra_specs": [
|
||||
{"name": "vds_max_v", "description": "Maximum drain-source voltage (Vds)", "unit": "V", "required": true},
|
||||
{"name": "id_max_a", "description": "Maximum continuous drain current (Id)", "unit": "A"},
|
||||
{"name": "rds_on_ohm", "description": "On-resistance (Rds_on)", "unit": "ohm"},
|
||||
{"name": "vgs_th_v", "description": "Gate threshold voltage (Vgs_th)", "unit": "V"}
|
||||
]
|
||||
},
|
||||
"discrete.transistor.bjt.npn": {
|
||||
"description": "NPN bipolar junction transistor",
|
||||
"extra_specs": [
|
||||
{"name": "vce_max_v", "description": "Maximum collector-emitter voltage (Vce)", "unit": "V", "required": true},
|
||||
{"name": "ic_max_a", "description": "Maximum collector current (Ic)", "unit": "A"},
|
||||
{"name": "hfe", "description": "DC current gain (hFE)"}
|
||||
]
|
||||
},
|
||||
"discrete.transistor.bjt.pnp": {
|
||||
"description": "PNP bipolar junction transistor",
|
||||
"extra_specs": [
|
||||
{"name": "vce_max_v", "description": "Maximum collector-emitter voltage (Vce)", "unit": "V", "required": true},
|
||||
{"name": "ic_max_a", "description": "Maximum collector current (Ic)", "unit": "A"},
|
||||
{"name": "hfe", "description": "DC current gain (hFE)"}
|
||||
]
|
||||
},
|
||||
"discrete.led": {
|
||||
"description": "Light-emitting diode",
|
||||
"extra_specs": [
|
||||
{"name": "forward_voltage_v", "description": "Typical forward voltage (Vf)", "unit": "V"},
|
||||
{"name": "forward_current_a", "description": "Typical/max forward current (If)", "unit": "A"},
|
||||
{"name": "color", "description": "LED color or wavelength"}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"type": "fuse",
|
||||
"specs": [
|
||||
{"name": "current_rating_a", "description": "Rated current", "unit": "A", "required": true},
|
||||
{"name": "voltage_rating_v", "description": "Maximum rated voltage", "unit": "V"},
|
||||
{"name": "breaking_capacity_a", "description": "Maximum breaking/interrupting capacity", "unit": "A"}
|
||||
],
|
||||
"subtypes": {
|
||||
"fuse": {
|
||||
"description": "Fuse (generic)"
|
||||
},
|
||||
"fuse.standard": {
|
||||
"description": "Standard fuse (one-time blow)"
|
||||
},
|
||||
"fuse.ptc_resettable": {
|
||||
"description": "PTC resettable fuse (polyfuse)",
|
||||
"extra_specs": [
|
||||
{"name": "hold_current_a", "description": "Maximum current without tripping (Ihold)", "unit": "A"},
|
||||
{"name": "trip_current_a", "description": "Minimum current that triggers trip (Itrip)", "unit": "A"},
|
||||
{"name": "resistance_ohm", "description": "Typical resistance at 25C (Rtyp)", "unit": "ohm"}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"type": "ic",
|
||||
"subtypes": {
|
||||
"ic.mcu": {
|
||||
"description": "Microcontroller",
|
||||
"example_mpn": "MSPM0G3507SPTR"
|
||||
},
|
||||
"ic.power.ldo": {
|
||||
"description": "Low-dropout voltage regulator",
|
||||
"example_mpn": "SPX3819M5-L-3-3"
|
||||
},
|
||||
"ic.power.switching_regulator": {
|
||||
"description": "Switching voltage regulator (buck, boost, buck-boost)"
|
||||
},
|
||||
"ic.power.pmic": {
|
||||
"description": "Power management IC (multi-rail, sequencing)"
|
||||
},
|
||||
"ic.interface.usb_uart_bridge": {
|
||||
"description": "USB to UART bridge IC",
|
||||
"example_mpn": "CH340E"
|
||||
},
|
||||
"ic.interface.level_shifter": {
|
||||
"description": "Voltage level translator/shifter"
|
||||
},
|
||||
"ic.interface.can_transceiver": {
|
||||
"description": "CAN bus transceiver"
|
||||
},
|
||||
"ic.interface.rs485_transceiver": {
|
||||
"description": "RS-485/RS-422 transceiver"
|
||||
},
|
||||
"ic.protection.esd": {
|
||||
"description": "ESD/TVS protection IC",
|
||||
"example_mpn": "USBLC6-2SC6"
|
||||
},
|
||||
"ic.sensor.accelerometer": {
|
||||
"description": "Accelerometer / IMU"
|
||||
},
|
||||
"ic.sensor.temperature": {
|
||||
"description": "Temperature sensor IC"
|
||||
},
|
||||
"ic.memory.flash": {
|
||||
"description": "NOR/NAND flash memory"
|
||||
},
|
||||
"ic.memory.eeprom": {
|
||||
"description": "EEPROM"
|
||||
},
|
||||
"ic.logic.buffer": {
|
||||
"description": "Buffer / line driver"
|
||||
},
|
||||
"ic.logic.gate": {
|
||||
"description": "Logic gate IC"
|
||||
},
|
||||
"ic.amplifier.opamp": {
|
||||
"description": "Operational amplifier"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"type": "passive",
|
||||
"specs": [
|
||||
{"name": "value_formatted", "description": "Human-readable value with SI prefix (e.g. 4.7 kohm, 100 nF)"},
|
||||
{"name": "tolerance", "description": "Tolerance specification (e.g. ±1%, ±10%)"},
|
||||
{"name": "package", "description": "Package type (e.g. 0603, 0805, 1206)"}
|
||||
],
|
||||
"subtypes": {
|
||||
"passive.resistor": {
|
||||
"description": "Chip resistor",
|
||||
"example_mpn": "0603WAF5101T5E",
|
||||
"extra_specs": [
|
||||
{"name": "value_ohms", "description": "Resistance value", "unit": "ohm", "required": true},
|
||||
{"name": "power_rating_w", "description": "Power rating", "unit": "W"}
|
||||
]
|
||||
},
|
||||
"passive.resistor.thick_film": {
|
||||
"description": "Thick film chip resistor",
|
||||
"extra_specs": [
|
||||
{"name": "value_ohms", "description": "Resistance value", "unit": "ohm", "required": true},
|
||||
{"name": "power_rating_w", "description": "Power rating", "unit": "W"}
|
||||
]
|
||||
},
|
||||
"passive.resistor.thin_film": {
|
||||
"description": "Thin film chip resistor",
|
||||
"extra_specs": [
|
||||
{"name": "value_ohms", "description": "Resistance value", "unit": "ohm", "required": true},
|
||||
{"name": "power_rating_w", "description": "Power rating", "unit": "W"}
|
||||
]
|
||||
},
|
||||
"passive.capacitor.ceramic": {
|
||||
"description": "Multi-layer ceramic capacitor (MLCC)",
|
||||
"example_mpn": "CL10B474KA8NNNC",
|
||||
"extra_specs": [
|
||||
{"name": "value_farads", "description": "Capacitance value", "unit": "F", "required": true},
|
||||
{"name": "voltage_rating_v", "description": "Rated voltage", "unit": "V"},
|
||||
{"name": "dielectric", "description": "Dielectric type (e.g. X7R, C0G, X5R)"}
|
||||
]
|
||||
},
|
||||
"passive.capacitor.tantalum": {
|
||||
"description": "Tantalum capacitor",
|
||||
"extra_specs": [
|
||||
{"name": "value_farads", "description": "Capacitance value", "unit": "F", "required": true},
|
||||
{"name": "voltage_rating_v", "description": "Rated voltage", "unit": "V"}
|
||||
]
|
||||
},
|
||||
"passive.capacitor.electrolytic": {
|
||||
"description": "Aluminum electrolytic capacitor",
|
||||
"extra_specs": [
|
||||
{"name": "value_farads", "description": "Capacitance value", "unit": "F", "required": true},
|
||||
{"name": "voltage_rating_v", "description": "Rated voltage", "unit": "V"}
|
||||
]
|
||||
},
|
||||
"passive.inductor": {
|
||||
"description": "Inductor / choke",
|
||||
"extra_specs": [
|
||||
{"name": "value_henries", "description": "Inductance value", "unit": "H", "required": true},
|
||||
{"name": "current_rating_a", "description": "Saturation / rated current", "unit": "A"},
|
||||
{"name": "dcr_ohms", "description": "DC resistance", "unit": "ohm"}
|
||||
]
|
||||
},
|
||||
"passive.ferrite_bead": {
|
||||
"description": "Ferrite bead",
|
||||
"extra_specs": [
|
||||
{"name": "impedance_ohm", "description": "Impedance at the test frequency", "unit": "ohm", "required": true},
|
||||
{"name": "current_rating_a", "description": "Rated current", "unit": "A"},
|
||||
{"name": "dcr_ohms", "description": "DC resistance", "unit": "ohm"}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"type": "switch",
|
||||
"specs": [
|
||||
{"name": "voltage_rating_v", "description": "Maximum rated voltage", "unit": "V"},
|
||||
{"name": "current_rating_a", "description": "Maximum rated current", "unit": "A"}
|
||||
],
|
||||
"subtypes": {
|
||||
"switch.tactile": {
|
||||
"description": "Tactile push-button switch",
|
||||
"extra_specs": [
|
||||
{"name": "contact_configuration", "description": "Contact arrangement (e.g. SPST-NO, SPST-NC)"}
|
||||
]
|
||||
},
|
||||
"switch.dip": {
|
||||
"description": "DIP switch",
|
||||
"extra_specs": [
|
||||
{"name": "positions", "description": "Number of independent switch positions"},
|
||||
{"name": "contact_configuration", "description": "Contact arrangement per position (e.g. SPST)"}
|
||||
]
|
||||
},
|
||||
"switch.slide": {
|
||||
"description": "Slide switch",
|
||||
"extra_specs": [
|
||||
{"name": "contact_configuration", "description": "Contact arrangement (e.g. SPDT, DPDT)"}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"type": "test_point",
|
||||
"subtypes": {
|
||||
"test_point": {
|
||||
"description": "Test point"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"type": "transformer",
|
||||
"specs": [
|
||||
{"name": "turns_ratio", "description": "Primary to secondary turns ratio"},
|
||||
{"name": "voltage_primary_v", "description": "Primary voltage rating", "unit": "V"},
|
||||
{"name": "voltage_secondary_v", "description": "Secondary voltage rating", "unit": "V"},
|
||||
{"name": "current_rating_a", "description": "Maximum current rating", "unit": "A"}
|
||||
],
|
||||
"subtypes": {
|
||||
"transformer.power": {
|
||||
"description": "Power transformer",
|
||||
"extra_specs": [
|
||||
{"name": "power_rating_w", "description": "Maximum power rating", "unit": "W"},
|
||||
{"name": "isolation_voltage_v", "description": "Isolation/withstand voltage between windings", "unit": "V"}
|
||||
]
|
||||
},
|
||||
"transformer.signal": {
|
||||
"description": "Signal / isolation transformer",
|
||||
"extra_specs": [
|
||||
{"name": "isolation_voltage_v", "description": "Isolation/withstand voltage between windings", "unit": "V"},
|
||||
{"name": "insertion_loss_db", "description": "Insertion loss", "unit": "dB"},
|
||||
{"name": "bandwidth_hz", "description": "Operating bandwidth (-3dB)", "unit": "Hz"}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,6 @@ def test_graph_parsers_models_taxonomy_load_from_src():
|
||||
assert "Native Periscope overlay" in path.read_text(encoding="utf-8")[:400]
|
||||
|
||||
|
||||
def test_taxonomy_dir_points_at_inherited_json():
|
||||
def test_taxonomy_dir_points_at_json_tree():
|
||||
ic = taxonomy.TAXONOMY_DIR / "ic.json"
|
||||
assert ic.is_file(), taxonomy.TAXONOMY_DIR
|
||||
assert "src/taxonomy" not in str(taxonomy.TAXONOMY_DIR)
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Native overlay: leftover PinScope modules src still imported resolve from src."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import backend.periscopex.bom_summary as bom_summary
|
||||
import backend.periscopex.derating as derating
|
||||
import backend.periscopex.led_current_check as led_current_check
|
||||
import backend.periscopex.pin_function_tokens as pin_function_tokens
|
||||
import backend.periscopex.pin_mux_check as pin_mux_check
|
||||
import backend.periscopex.resolve_passives as resolve_passives
|
||||
import backend.periscopex.utils as utils
|
||||
import backend.periscopex.validate as validate
|
||||
import backend.periscopex.validation_tools as validation_tools
|
||||
import backend.services.extraction as extraction
|
||||
import backend.services.pipeline as pipeline
|
||||
import backend.services.validation as validation
|
||||
from backend.repo_paths import skills_dir, taxonomy_dir
|
||||
|
||||
|
||||
def _src(mod) -> Path:
|
||||
return Path(mod.__file__).resolve()
|
||||
|
||||
|
||||
def test_leftover_periscopex_modules_load_from_src():
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
src = (root / "periscope" / "src" / "backend" / "periscopex").resolve()
|
||||
for mod, name in (
|
||||
(utils, "utils.py"),
|
||||
(resolve_passives, "resolve_passives.py"),
|
||||
(derating, "derating.py"),
|
||||
(bom_summary, "bom_summary.py"),
|
||||
(pin_mux_check, "pin_mux_check.py"),
|
||||
(led_current_check, "led_current_check.py"),
|
||||
(pin_function_tokens, "pin_function_tokens.py"),
|
||||
(validate, "validate.py"),
|
||||
(validation_tools, "validation_tools.py"),
|
||||
):
|
||||
path = _src(mod)
|
||||
assert path == src / name, path
|
||||
assert "Native Periscope overlay" in path.read_text(encoding="utf-8")[:500]
|
||||
|
||||
|
||||
def test_pipeline_extraction_validation_load_from_src():
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
src = (root / "periscope" / "src" / "backend" / "services").resolve()
|
||||
for mod, name in (
|
||||
(pipeline, "pipeline.py"),
|
||||
(extraction, "extraction.py"),
|
||||
(validation, "validation.py"),
|
||||
):
|
||||
path = _src(mod)
|
||||
assert path == src / name, path
|
||||
assert "Native Periscope overlay" in path.read_text(encoding="utf-8")[:500]
|
||||
|
||||
|
||||
def test_pipeline_still_reexports_job_workspace():
|
||||
from backend.services.job_workspace import EventBroker, PipelineWorkspace
|
||||
|
||||
assert pipeline.EventBroker is EventBroker
|
||||
assert pipeline.PipelineWorkspace is PipelineWorkspace
|
||||
assert pipeline.set_broker.__name__ == "set_broker"
|
||||
|
||||
|
||||
def test_native_taxonomy_and_skills_trees():
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
tax = taxonomy_dir()
|
||||
skills = skills_dir()
|
||||
assert (tax / "ic.json").is_file(), tax
|
||||
assert (skills / "extract-pintable" / "SKILL.md").is_file(), skills
|
||||
# Prefer src copies when not running in the Docker /app layout.
|
||||
if not Path("/app/taxonomy").is_dir():
|
||||
assert tax == (root / "periscope" / "src" / "taxonomy").resolve()
|
||||
assert skills == (root / "periscope" / "src" / "skills").resolve()
|
||||
Reference in New Issue
Block a user