diff --git a/periscope/dependency/frontend/src/app/opengraph-image.tsx b/periscope/dependency/frontend/src/app/opengraph-image.tsx
index 397044a..683d301 100644
--- a/periscope/dependency/frontend/src/app/opengraph-image.tsx
+++ b/periscope/dependency/frontend/src/app/opengraph-image.tsx
@@ -26,23 +26,15 @@ export default function Image() {
diff --git a/periscope/dependency/frontend/src/components/legal/changelog-timeline.tsx b/periscope/dependency/frontend/src/components/legal/changelog-timeline.tsx
index 5d216ed..79c2c50 100644
--- a/periscope/dependency/frontend/src/components/legal/changelog-timeline.tsx
+++ b/periscope/dependency/frontend/src/components/legal/changelog-timeline.tsx
@@ -1,4 +1,5 @@
-import { Cpu, ArrowLeft } from "lucide-react";
+import { ArrowLeft } from "lucide-react";
+import { PeriscopeMark } from "@/components/brand/periscope-mark";
import Link from "next/link";
import { OPERATOR_NAME } from "@/lib/site";
@@ -96,7 +97,7 @@ export function ChangelogTimeline({ content }: { content: string }) {
Back to home
-
+
Periscope
diff --git a/periscope/dependency/frontend/src/components/legal/legal-page.tsx b/periscope/dependency/frontend/src/components/legal/legal-page.tsx
index e5897e2..9ffa0e0 100644
--- a/periscope/dependency/frontend/src/components/legal/legal-page.tsx
+++ b/periscope/dependency/frontend/src/components/legal/legal-page.tsx
@@ -1,4 +1,5 @@
-import { Cpu, ArrowLeft } from "lucide-react";
+import { ArrowLeft } from "lucide-react";
+import { PeriscopeMark } from "@/components/brand/periscope-mark";
import Link from "next/link";
import type { ReactNode } from "react";
import Markdown from "react-markdown";
@@ -115,7 +116,7 @@ export function LegalPageShell({ children }: { children: ReactNode }) {
Back to home
-
+
Periscope
diff --git a/periscope/src/backend/periscopex/graph.py b/periscope/src/backend/periscopex/graph.py
new file mode 100644
index 0000000..9045a9e
--- /dev/null
+++ b/periscope/src/backend/periscopex/graph.py
@@ -0,0 +1,447 @@
+"""Native Periscope overlay: graph builder (PinScope original remains in dependency/).
+
+Build a DesignGraph deterministically from netlist + BOM + extracted datasheets.
+"""
+
+from __future__ import annotations
+
+import json
+import re
+from pathlib import Path
+
+from backend.periscopex.utils import safe_mpn
+from backend.periscopex.models import (
+ CadIndexEntry,
+ Component,
+ ComponentConstraints,
+ ComponentModel,
+ ComponentSpecs,
+ ComponentType,
+ DesignGraph,
+ Net,
+ NetType,
+ PinConnection,
+ SimpleComponentSpecs,
+)
+
+# Datasheets are loaded here for pin-name enrichment during graph build,
+# but NOT embedded into the graph. The validator loads them separately.
+from backend.periscopex.parsers import parse_bom, parse_netlist_any
+from backend.periscopex.resolve_passives import SkippedItem, resolve_bom, resolved_to_specs
+
+# ---------------------------------------------------------------------------
+# Component type classification
+# ---------------------------------------------------------------------------
+
+_PREFIX_TYPE: dict[str, ComponentType] = {
+ "R": ComponentType.RESISTOR,
+ "RN": ComponentType.RESISTOR,
+ "C": ComponentType.CAPACITOR,
+ "L": ComponentType.INDUCTOR,
+ "FB": ComponentType.INDUCTOR,
+ "U": ComponentType.IC,
+ "IC": ComponentType.IC,
+ "J": ComponentType.CONNECTOR,
+ "X": ComponentType.CRYSTAL,
+ "Y": ComponentType.CRYSTAL,
+ "D": ComponentType.DISCRETE,
+ "LED": ComponentType.DISCRETE,
+ "Q": ComponentType.DISCRETE,
+ "T": ComponentType.TRANSFORMER,
+ "F": ComponentType.FUSE,
+ "SW": ComponentType.SWITCH,
+ "TP": ComponentType.TEST_POINT,
+ "FM": ComponentType.FIDUCIAL,
+ "MH": ComponentType.MECHANICAL,
+}
+
+# Fallback footprint patterns for designators whose prefix isn't a known
+# EE convention (e.g. pure-numeric refs like "4", descriptive refs like
+# "CV GND", "CAN BUS IN", "12V ACTIVE"). Order matters — first match wins.
+_FOOTPRINT_TYPE_PATTERNS: list[tuple[re.Pattern, ComponentType]] = [
+ (re.compile(
+ r"(?i)(?:^|[\s_])("
+ r"CONN(?:_|\b)|TERM(?:\b|_BLK)|HEADER|SOCKET|JACK|RECEPTACLE|PLUG|"
+ r"SCREW\s*TERM|PINHEADER|BARREL|BANANA|XT30|XT60|XT90|USB|"
+ r"WURTH\s*746\d|TE\s*282834|TE\s*2828\d|MOLEX|JST"
+ r")"
+ ), ComponentType.CONNECTOR),
+ (re.compile(r"(?i)TestPoint|TEST[_\s]POINT|\bTP_"), ComponentType.TEST_POINT),
+ (re.compile(r"(?i)^LED[\s_]|\bLED\s+\d{3,4}"), ComponentType.DISCRETE),
+ (re.compile(r"(?i)^CAP[\s_]|\bCAP_|CAPACITOR"), ComponentType.CAPACITOR),
+ (re.compile(r"(?i)^RES[\s_]|\bRES_|RESISTOR"), ComponentType.RESISTOR),
+ (re.compile(r"(?i)^IND[\s_]|\bIND_|INDUCTOR"), ComponentType.INDUCTOR),
+ (re.compile(r"(?i)DO214|DO220|SOD\d|SMD?J5|SMB_|SOT-?23"), ComponentType.DISCRETE),
+]
+
+
+def _classify_component(ref: str, footprint: str) -> ComponentType:
+ """Classify a component by its reference prefix, with footprint fallback."""
+ prefix = re.match(r"^[A-Za-z]+", ref)
+ if prefix:
+ t = _PREFIX_TYPE.get(prefix.group())
+ if t is not None:
+ return t
+ # Fallback: use footprint hints when the ref prefix isn't recognised
+ # (e.g. pure-numeric refs, or descriptive refs like "CV GND", "12V ACTIVE")
+ fp = footprint or ""
+ for pattern, ctype in _FOOTPRINT_TYPE_PATTERNS:
+ if pattern.search(fp):
+ return ctype
+ return ComponentType.UNKNOWN
+
+
+# ---------------------------------------------------------------------------
+# Net type / voltage inference
+# ---------------------------------------------------------------------------
+
+# Patterns for common power rail names -> nominal voltage
+_VOLTAGE_RE: list[tuple[re.Pattern, float]] = [
+ (re.compile(r"^\+(\d+)V(\d+)$"), 0), # +3V3 -> 3.3, +1V35 -> 1.35
+ (re.compile(r"^\+(\d+(?:\.\d+)?)V$"), 0), # +5V -> 5.0, +12V -> 12.0
+]
+
+
+def _parse_rail_voltage(name: str) -> float | None:
+ """Try to extract a numeric voltage from a power-rail net name.
+
+ Handles patterns like: +3V3, +5V, VDD_1V8, DVDD3V3, VBUS_5V0, etc.
+ """
+ # +3V3 style: digits + V + digits -> "3.3"
+ m = re.match(r"^\+(\d+)V(\d+)$", name)
+ if m:
+ return float(f"{m.group(1)}.{m.group(2)}")
+
+ # +5V style
+ m = re.match(r"^\+(\d+(?:\.\d+)?)V$", name)
+ if m:
+ return float(m.group(1))
+
+ # Embedded voltage: *_1V8, *_3V3, *1V35, *3V3, etc.
+ m = re.search(r"(\d+)V(\d+)", name)
+ if m:
+ return float(f"{m.group(1)}.{m.group(2)}")
+
+ # Embedded voltage: *_5V0, *_12V, *5V, etc.
+ m = re.search(r"(\d+(?:\.\d+)?)V(?:\d|$|_)", name)
+ if m:
+ return float(m.group(1))
+
+ return None
+
+
+# Net name prefixes that indicate power rails (case-insensitive)
+_POWER_PREFIXES = (
+ "VCC", "VDD", "VBUS", "VBAT", "VSYS", "VSUP", "VPWR",
+ "AVDD", "DVDD", "AVCC", "DVCC", "PVDD", "PVCC",
+ "V_",
+)
+
+# Net name suffixes that indicate ground (case-insensitive)
+_GROUND_SUFFIXES = ("_GND", "GND")
+_GROUND_NAMES = {"GND", "AGND", "DGND", "PGND", "VSS", "AVSS", "DVSS", "PVSS"}
+
+
+def _infer_net_properties(name: str) -> tuple[NetType, float | None]:
+ """Deterministically classify a net by its name."""
+ upper = name.upper()
+
+ # Ground nets — exact names and suffixes
+ if upper in _GROUND_NAMES or any(upper.endswith(s) for s in _GROUND_SUFFIXES):
+ return NetType.GROUND, 0.0
+
+ # Power rails: names starting with "+"
+ if name.startswith("+"):
+ voltage = _parse_rail_voltage(name)
+ return NetType.POWER, voltage
+
+ # Power rails: common prefixes (VDD, VCC, VBUS, etc.)
+ if any(upper.startswith(p) for p in _POWER_PREFIXES):
+ voltage = _parse_rail_voltage(name)
+ return NetType.POWER, voltage
+
+ # KiCad-style rails: 3V3_DIGITAL, 1V8_SI4684, 5V_USB (not I2C1-SCL-3V3).
+ if re.match(r"^\d+V\d*", upper):
+ voltage = _parse_rail_voltage(name)
+ return NetType.POWER, voltage
+
+ # Everything else is a signal
+ return NetType.SIGNAL, None
+
+
+# ---------------------------------------------------------------------------
+# Datasheet loading
+# ---------------------------------------------------------------------------
+
+
+def _load_datasheets(directory: str | Path) -> dict[str, tuple[Path, ComponentConstraints]]:
+ """Load all extracted datasheet JSONs, keyed by MPN."""
+ result: dict[str, tuple[Path, ComponentConstraints]] = {}
+ dirpath = Path(directory)
+ if not dirpath.is_dir():
+ return result
+
+ for json_file in dirpath.glob("*.json"):
+ raw = json.loads(json_file.read_text())
+ constraints = ComponentConstraints.model_validate(raw)
+ result[constraints.mpn] = (json_file, constraints)
+
+ return result
+
+
+def _match_datasheet(
+ mpn: str | None,
+ datasheets: dict[str, tuple[Path, ComponentConstraints]],
+) -> tuple[Path | None, ComponentConstraints | None]:
+ """Match a BOM MPN to an extracted datasheet. Tries exact then normalized."""
+ if not mpn:
+ return None, None
+
+ # Exact match
+ if mpn in datasheets:
+ return datasheets[mpn]
+
+ # Normalize: strip common suffixes, lowercase compare
+ def _norm(s: str) -> str:
+ return re.sub(r"[/_\-\s]", "", s).upper()
+
+ mpn_norm = _norm(mpn)
+ for ds_mpn, (path, constraints) in datasheets.items():
+ if _norm(ds_mpn) == mpn_norm:
+ return path, constraints
+
+ return None, None
+
+
+# ---------------------------------------------------------------------------
+# Component model loading / saving (passive specs cache)
+# ---------------------------------------------------------------------------
+
+
+def _load_component_models(directory: str | Path) -> dict[str, ComponentSpecs]:
+ """Load all component model JSONs, keyed by MPN."""
+ result: dict[str, ComponentSpecs] = {}
+ dirpath = Path(directory)
+ if not dirpath.is_dir():
+ return result
+ for json_file in dirpath.glob("*.json"):
+ raw = json.loads(json_file.read_text())
+ model = ComponentModel.model_validate(raw)
+ result[model.mpn] = model.specs
+ return result
+
+
+def _save_component_model(mpn: str, specs: ComponentSpecs, directory: Path) -> None:
+ """Save a ComponentModel to the component-models directory."""
+ directory.mkdir(parents=True, exist_ok=True)
+ safe_name = safe_mpn(mpn)
+ model = ComponentModel(mpn=mpn, specs=specs)
+ (directory / f"{safe_name}.json").write_text(
+ model.model_dump_json(indent=2) + "\n"
+ )
+
+
+# ---------------------------------------------------------------------------
+# Graph builder
+# ---------------------------------------------------------------------------
+
+
+def build_graph(
+ netlist_path: str | Path,
+ bom_path: str | Path,
+ datasheets_dir: str | Path = "datasheets/extracted",
+ patterns_dir: str | Path = "component-patterns",
+ component_models_dir: str | Path = "component-models",
+ *,
+ reference_col: str = "Reference",
+ mpn_col: str = "Manufacturer Part Number",
+ skipped: list[SkippedItem] | None = None,
+ include_subdesigns: set[str] | None = None,
+ pcb_path: str | Path | None = None,
+) -> DesignGraph:
+ """Build a DesignGraph deterministically from project files.
+
+ Steps:
+ 1. Parse netlist -> parts (ref, footprint) and nets (name, pin connections)
+ 2. Parse BOM -> values, MPNs, LCSC codes per reference
+ 3. Load extracted datasheets and match by MPN
+ 4. Resolve passive specs from patterns + cached component models
+ 5. Assemble components with classified type, linked constraints, and specs
+ 6. Assemble nets with inferred type/voltage and enriched pin names
+
+ When ``pcb_path`` points at a ``.kicad_pcb``, pad nets from the board replace
+ schematic-derived connectivity (KiCad board nets are authoritative).
+ """
+ # Parse BOM first so we can feed known refs into the netlist parser —
+ # PADS-PCB netlists allow multi-word designators (e.g. "CV GND"), which
+ # only tokenise correctly with the BOM's ref list as a lookup. EDIF
+ # netlists ignore known_refs (designators are unambiguous tokens).
+ bom = parse_bom(bom_path, reference_col=reference_col, mpn_col=mpn_col)
+ bom_fields = {}
+ for ref, entry in bom.items():
+ row = {"mpn": entry.get("mpn"), "value": entry.get("value", "")}
+ if "dnp" in entry:
+ row["dnp"] = entry.get("dnp")
+ if entry.get("variant") is not None:
+ row["variant"] = entry.get("variant")
+ bom_fields[ref] = row
+ schematic_fields: dict[str, dict] = {}
+ parts, raw_nets, fmt = parse_netlist_any(
+ netlist_path,
+ known_refs=set(bom.keys()),
+ include_subdesigns=include_subdesigns,
+ )
+ if pcb_path is not None:
+ pcb = Path(pcb_path)
+ if pcb.is_file():
+ from backend.periscopex.parsers_kicad_pcb import nets_from_pcb, parse_kicad_pcb
+
+ layout = parse_kicad_pcb(pcb)
+ pcb_nets = nets_from_pcb(layout)
+ if pcb_nets:
+ raw_nets = pcb_nets
+ for ref, fp in layout.footprints.items():
+ parts.setdefault(ref, fp.footprint or "")
+ if fmt.startswith("kicad"):
+ from backend.periscopex.parsers_kicad import kicad_part_fields
+ for ref, extra in kicad_part_fields(netlist_path).items():
+ schematic_fields[ref] = {
+ "mpn": extra.get("mpn"),
+ "value": extra.get("value", ""),
+ "cad_uuid": extra.get("cad_uuid") or "",
+ "cad_sheet": extra.get("cad_sheet") or "",
+ }
+ entry = bom.setdefault(
+ ref,
+ {"value": "", "footprint": "", "mpn": None, "lcsc": None, "datasheet_url": None},
+ )
+ if extra.get("mpn") and (
+ not entry.get("mpn") or entry.get("mpn") == entry.get("value")
+ ):
+ entry["mpn"] = extra["mpn"]
+ if extra.get("lcsc") and not entry.get("lcsc"):
+ entry["lcsc"] = extra["lcsc"]
+ if extra.get("value") and not entry.get("value"):
+ entry["value"] = extra["value"]
+ if extra.get("footprint") and not entry.get("footprint"):
+ entry["footprint"] = extra["footprint"]
+ datasheets = _load_datasheets(datasheets_dir)
+
+ # --- Resolve passive specs ------------------------------------------------
+ models_dir = Path(component_models_dir)
+ mpn_specs: dict[str, ComponentSpecs] = _load_component_models(models_dir)
+ mpn_subtype: dict[str, str] = {} # MPN -> component_subtype from patterns
+
+ for rp in resolve_bom(bom_path, patterns_dir, reference_col=reference_col, mpn_col=mpn_col, skipped=skipped):
+ if rp.component_subtype:
+ mpn_subtype[rp.mpn] = rp.component_subtype
+ if rp.mpn not in mpn_specs:
+ try:
+ specs = resolved_to_specs(rp)
+ mpn_specs[rp.mpn] = specs
+ _save_component_model(rp.mpn, specs, models_dir)
+ except Exception as e:
+ if skipped is not None:
+ skipped.append(SkippedItem(rp.mpn, "passive_specs", str(e)))
+
+ components: dict[str, Component] = {}
+ nets: dict[str, Net] = {}
+
+ # --- Build components ---------------------------------------------------
+ # Some PADS-PCB netlist exports omit the *PART* section. When that happens
+ # derive the component list from BOM entries + refs found in nets so the
+ # graph is still fully populated.
+ if not parts:
+ net_refs = {ref for pins in raw_nets.values() for ref, _ in pins}
+ all_refs = set(bom.keys()) | net_refs
+ parts = {ref: bom.get(ref, {}).get("footprint", "") for ref in all_refs}
+
+ for ref, footprint in parts.items():
+ bom_entry = bom.get(ref, {})
+ value = bom_entry.get("value", "")
+ mpn = bom_entry.get("mpn") or None
+ if not mpn and _classify_component(ref, footprint) == ComponentType.IC:
+ mpn = (value or "").strip() or None
+
+ components[ref] = Component(
+ reference=ref,
+ value=value,
+ footprint=footprint,
+ component_type=_classify_component(ref, footprint),
+ mpn=mpn,
+ pins={},
+ )
+
+ # Build MPN -> constraints lookup for pin-name enrichment and subtype
+ _constraints_by_ref: dict[str, ComponentConstraints] = {}
+ for ref, comp in components.items():
+ if comp.mpn:
+ _, constraints = _match_datasheet(comp.mpn, datasheets)
+ if constraints:
+ _constraints_by_ref[ref] = constraints
+ if constraints.component_subtype:
+ comp.component_subtype = constraints.component_subtype
+ # Attach specs (passive or simple component) and subtype
+ if comp.mpn in mpn_specs:
+ comp.specs = mpn_specs[comp.mpn]
+ # SimpleComponentSpecs carries its own subtype
+ if not comp.component_subtype:
+ s = mpn_specs[comp.mpn]
+ if hasattr(s, "component_subtype") and s.component_subtype:
+ comp.component_subtype = s.component_subtype
+ if not comp.component_subtype and comp.mpn in mpn_subtype:
+ comp.component_subtype = mpn_subtype[comp.mpn]
+
+ # --- Build nets and wire up pins ----------------------------------------
+
+ for net_name, pin_list in raw_nets.items():
+ net_type, voltage = _infer_net_properties(net_name)
+
+ pin_connections: list[PinConnection] = []
+ for ref, pin_num in pin_list:
+ # Record on the component side: pin -> net
+ if ref in components:
+ components[ref].pins[pin_num] = net_name
+
+ # Enrich pin name from datasheet (IC constraints or simple specs)
+ pin_name = None
+ constraints = _constraints_by_ref.get(ref)
+ if constraints:
+ pin_obj = constraints.pin_by_number(pin_num)
+ if pin_obj:
+ pin_name = pin_obj.name
+ elif ref in components and components[ref].mpn:
+ # Check SimpleComponentSpecs pintable
+ s = mpn_specs.get(components[ref].mpn)
+ if isinstance(s, SimpleComponentSpecs) and s.pintable:
+ pin_obj = s.pin_by_number(pin_num)
+ if pin_obj:
+ pin_name = pin_obj.name
+
+ pin_connections.append(PinConnection(
+ component_ref=ref,
+ pin_number=pin_num,
+ pin_name=pin_name,
+ ))
+
+ nets[net_name] = Net(
+ name=net_name,
+ net_type=net_type,
+ voltage=voltage,
+ pins=pin_connections,
+ )
+
+ cad_index: dict[str, CadIndexEntry] = {}
+ for ref, extra in schematic_fields.items():
+ uuid = extra.get("cad_uuid") or ""
+ sheet = extra.get("cad_sheet") or ""
+ if uuid or sheet:
+ cad_index[ref] = CadIndexEntry(uuid=uuid, sheet=sheet)
+
+ return DesignGraph(
+ components=components,
+ nets=nets,
+ bom_fields=bom_fields,
+ schematic_fields=schematic_fields,
+ cad_index=cad_index,
+ )
diff --git a/periscope/src/backend/periscopex/models.py b/periscope/src/backend/periscopex/models.py
new file mode 100644
index 0000000..b621db6
--- /dev/null
+++ b/periscope/src/backend/periscopex/models.py
@@ -0,0 +1,528 @@
+"""Native Periscope overlay: Pydantic models (PinScope original remains in dependency/).
+
+Datasheet constraints, design graph, and PCB layout types.
+"""
+
+from __future__ import annotations
+
+from enum import Enum
+from typing import Annotated, Any, Literal
+
+from pydantic import BaseModel, Discriminator, Field, Tag, field_validator, model_validator
+
+
+class Pin(BaseModel):
+ number: int | str
+ name: str
+ description: str | None = None
+ functions: list[str] | None = None
+
+
+class PackageInfo(BaseModel):
+ base_family: str
+ package: str
+ pin_count: int
+ description: str | None = None
+
+
+class AbsMaxRating(BaseModel):
+ parameter: str
+ min: float | None = None
+ max: float | None = None
+ unit: str
+ source_page: int
+
+
+class Rule(BaseModel):
+ rule_id: str | None = None # {MPN}-{001}
+ description: str
+ source_page: int
+
+
+def _check_subtype(v: object) -> str | None:
+ """Shared pre-validator for component_subtype fields."""
+ if v is None or v == "":
+ return None
+ from backend.periscopex.taxonomy import validate_subtype
+ return validate_subtype(str(v))
+
+
+class InternalFeatures(BaseModel):
+ """Block-diagram extras: ESD clamps, on-die pull-ups, analog switches."""
+ esd_clamp_pins: list[str] = []
+ pullup_pins: list[str] = []
+ analog_switch: list[str] = []
+
+
+class ComponentConstraints(BaseModel):
+ mpn: str
+ model_version: str = "1.0.0" # semver; bumped on prune (patch) or skill update (minor)
+ component_subtype: str | None = None # dotted taxonomy path, e.g. "ic.ldo", "ic.mcu"
+ package_info: PackageInfo | None = None
+ pintable: list[Pin]
+ absolute_maximum_ratings: list[AbsMaxRating]
+ rules: list[Rule]
+ internal_features: InternalFeatures | None = None
+ layout_rules: list[dict] = []
+
+ _validate_subtype = field_validator("component_subtype", mode="before")(
+ staticmethod(_check_subtype)
+ )
+
+ def pin_by_number(self, number: int | str) -> Pin | None:
+ """Look up a pin by its number."""
+ for p in self.pintable:
+ if str(p.number) == str(number):
+ return p
+ return None
+
+
+# ---------------------------------------------------------------------------
+# Design graph models
+# ---------------------------------------------------------------------------
+
+
+class NetType(str, Enum):
+ POWER = "power"
+ GROUND = "ground"
+ SIGNAL = "signal"
+ UNKNOWN = "unknown"
+
+
+class ComponentType(str, Enum):
+ RESISTOR = "resistor"
+ CAPACITOR = "capacitor"
+ INDUCTOR = "inductor"
+ IC = "ic"
+ CONNECTOR = "connector"
+ CRYSTAL = "crystal"
+ DISCRETE = "discrete"
+ TRANSFORMER = "transformer"
+ FUSE = "fuse"
+ SWITCH = "switch"
+ TEST_POINT = "test_point"
+ FIDUCIAL = "fiducial"
+ MECHANICAL = "mechanical"
+ UNKNOWN = "unknown"
+
+
+# ---------------------------------------------------------------------------
+# Component specs taxonomy — type-specific, standardised-unit models
+# ---------------------------------------------------------------------------
+
+
+class ResistorSpecs(BaseModel):
+ """Standardised resistor parameters. Value always in ohms."""
+ specs_type: Literal["resistor"] = "resistor"
+ component_subtype: str | None = None # e.g. "passive.resistor"
+ value_ohms: float
+ value_formatted: str
+ tolerance: str | None = None # "±1%" or "±0.5ohm"
+ package: str | None = None
+ power_rating_w: str | None = None
+
+ _validate_subtype = field_validator("component_subtype", mode="before")(
+ staticmethod(_check_subtype)
+ )
+
+
+class CapacitorSpecs(BaseModel):
+ """Standardised capacitor parameters. Value always in farads."""
+ specs_type: Literal["capacitor"] = "capacitor"
+ component_subtype: str | None = None # e.g. "passive.capacitor.ceramic"
+ value_farads: float
+ value_formatted: str
+ tolerance: str | None = None # "±10%" or "±0.25pF"
+ package: str | None = None
+ voltage_rating_v: str | None = None
+ dielectric: str | None = None
+
+ _validate_subtype = field_validator("component_subtype", mode="before")(
+ staticmethod(_check_subtype)
+ )
+
+
+class InductorSpecs(BaseModel):
+ """Standardised inductor / ferrite-bead parameters."""
+ specs_type: Literal["inductor"] = "inductor"
+ component_subtype: str | None = None # e.g. "passive.inductor" or "passive.ferrite_bead"
+ value_henries: float | None = None
+ value_formatted: str
+ tolerance: str | None = None # "±5%" or "±0.1uH"
+ package: str | None = None
+ current_rating_a: str | None = None
+ dcr_ohms: float | None = None
+ impedance_ohm: float | None = None # ferrite beads: Z at test frequency
+
+ _validate_subtype = field_validator("component_subtype", mode="before")(
+ staticmethod(_check_subtype)
+ )
+
+ @model_validator(mode="after")
+ def _require_primary_value(self) -> InductorSpecs:
+ if self.component_subtype == "passive.ferrite_bead":
+ if self.impedance_ohm is None:
+ raise ValueError("ferrite bead requires impedance_ohm")
+ return self
+ if self.value_henries is None:
+ raise ValueError("inductor requires value_henries")
+ return self
+
+
+class SimpleComponentSpecs(BaseModel):
+ """Specs for discrete/simple components. Schema defined in taxonomy JSON."""
+ specs_type: str # taxonomy type: "discrete", "connector", "crystal", etc.
+ component_subtype: str | None = None
+ values: dict[str, float | str | None] = {}
+ pintable: list[Pin] = []
+ package_info: PackageInfo | None = None
+
+ _validate_subtype = field_validator("component_subtype", mode="before")(
+ staticmethod(_check_subtype)
+ )
+
+ def pin_by_number(self, number: int | str) -> Pin | None:
+ """Look up a pin by its number."""
+ for p in self.pintable:
+ if str(p.number) == str(number):
+ return p
+ return None
+
+
+def _specs_tag(v: Any) -> str:
+ """Route to the correct specs model based on specs_type."""
+ st = v.get("specs_type") if isinstance(v, dict) else v.specs_type
+ return st if st in ("resistor", "capacitor", "inductor") else "simple"
+
+
+ComponentSpecs = Annotated[
+ Annotated[ResistorSpecs, Tag("resistor")]
+ | Annotated[CapacitorSpecs, Tag("capacitor")]
+ | Annotated[InductorSpecs, Tag("inductor")]
+ | Annotated[SimpleComponentSpecs, Tag("simple")],
+ Discriminator(_specs_tag),
+]
+
+
+class ComponentModel(BaseModel):
+ """Persisted specs file — one per MPN in component-models/."""
+ mpn: str
+ specs: ComponentSpecs
+
+
+# ---------------------------------------------------------------------------
+# Design graph models
+# ---------------------------------------------------------------------------
+
+
+class PinConnection(BaseModel):
+ """A pin on a component that participates in a net."""
+ component_ref: str
+ pin_number: str
+ pin_name: str | None = None # enriched from datasheet pintable
+
+
+class Net(BaseModel):
+ """An electrical net with mutable type/voltage for agent refinement."""
+ name: str
+ net_type: NetType = NetType.UNKNOWN
+ voltage: float | None = None
+ pins: list[PinConnection] = []
+
+
+class Component(BaseModel):
+ """A placed component in the design graph (topology only)."""
+ reference: str
+ value: str
+ footprint: str
+ component_type: ComponentType = ComponentType.UNKNOWN
+ component_subtype: str | None = None # dotted taxonomy path, e.g. "ic.ldo", "ic.mcu"
+ mpn: str | None = None
+ pins: dict[str, str] = {} # pin_number -> net_name
+ specs: ComponentSpecs | None = None
+
+ _validate_subtype = field_validator("component_subtype", mode="before")(
+ staticmethod(_check_subtype)
+ )
+
+
+class CadIndexEntry(BaseModel):
+ """KiCad symbol identity for plugin pan-and-zoom."""
+ uuid: str = ""
+ sheet: str = ""
+
+
+class DesignGraph(BaseModel):
+ """
+ Bipartite design graph: Components <-> Nets.
+
+ Traversal paths:
+ component.pins[pin_num] -> net_name -> graph.nets[net_name].pins -> other components
+ net.pins[i].component_ref -> graph.components[ref] -> its other pins/nets
+ """
+ components: dict[str, Component] = {}
+ nets: dict[str, Net] = {}
+ # KiCad property table vs uploaded BOM (empty on PADS/EDIF).
+ bom_fields: dict[str, dict] = {}
+ schematic_fields: dict[str, dict] = {}
+ cad_index: dict[str, CadIndexEntry] = {}
+
+ # -- Traversal helpers --------------------------------------------------
+
+ def components_on_net(self, net_name: str) -> list[str]:
+ """All component refs connected to a net."""
+ net = self.nets.get(net_name)
+ if not net:
+ return []
+ return list({pc.component_ref for pc in net.pins})
+
+ def nets_of_component(self, ref: str) -> list[str]:
+ """All net names a component touches."""
+ comp = self.components.get(ref)
+ if not comp:
+ return []
+ return list(set(comp.pins.values()))
+
+ def neighbors(self, ref: str) -> dict[str, list[str]]:
+ """Components sharing a net with *ref*, grouped by net name."""
+ result: dict[str, list[str]] = {}
+ for net_name in self.nets_of_component(ref):
+ others = [r for r in self.components_on_net(net_name) if r != ref]
+ if others:
+ result[net_name] = others
+ return result
+
+ def components_by_type(self, comp_type: ComponentType) -> list[str]:
+ """All refs matching a component type."""
+ return [r for r, c in self.components.items() if c.component_type == comp_type]
+
+ def power_nets(self) -> list[Net]:
+ """All power and ground nets."""
+ return [n for n in self.nets.values() if n.net_type in (NetType.POWER, NetType.GROUND)]
+
+ def capacitors_on_net(self, net_name: str) -> list[str]:
+ """Capacitor refs connected to a net (useful for decoupling checks)."""
+ return [
+ r for r in self.components_on_net(net_name)
+ if (c := self.components.get(r)) is not None
+ and c.component_type == ComponentType.CAPACITOR
+ ]
+
+ def components_by_subtype(self, prefix: str) -> list[str]:
+ """All refs whose component_subtype starts with *prefix*.
+
+ Examples:
+ components_by_subtype("ic.power") -> all power ICs
+ components_by_subtype("passive.capacitor") -> all capacitors
+ components_by_subtype("passive") -> all passives
+ """
+ prefix_dot = prefix if prefix.endswith(".") else prefix + "."
+ return [
+ r for r, c in self.components.items()
+ if c.component_subtype and (
+ c.component_subtype == prefix
+ or c.component_subtype.startswith(prefix_dot)
+ )
+ ]
+
+ def pin_net(self, ref: str, pin_number: str) -> str | None:
+ """Net name for a specific pin on a component."""
+ comp = self.components.get(ref)
+ if not comp:
+ return None
+ return comp.pins.get(pin_number)
+
+
+# ---------------------------------------------------------------------------
+# Validation report models
+# ---------------------------------------------------------------------------
+
+
+class Finding(BaseModel):
+ """A single review finding — an issue found during direct datasheet review."""
+ finding_id: str | None = None
+ designator: str
+ mpn: str = ""
+ aspect: str | None = None # "power_supply", "clock", etc. (for complex ICs)
+ finding: str # What was observed in the actual circuit
+ why: str = "" # Why it matters — from the datasheet
+ source_page: int | None = None # Datasheet page (null for deterministic checks)
+ source_quote: str = "" # Verbatim datasheet text supporting the finding (for PDF highlight)
+ source_designator: str | None = None # Designator whose datasheet source_page/source_quote refer to; None = this finding's own `designator`. Set when the evidence came from a connected component's datasheet excerpt (get_datasheet_excerpt), so the viewer opens the right PDF at the right page.
+ status: Literal["ERROR", "WARNING", "INFO"]
+ recommendation: str = ""
+ reference: str = ""
+ source: str | None = None # None/"review" = LLM; "pin_mux_check"/"led_current_check"/"supply_decoupling_check"/… = deterministic
+ net: str | None = None # net name for CAD telemetry / SI filters
+ pins: list[str] = [] # e.g. ["U3.54"] for pan-and-zoom
+ rule_id: str | None = None # deterministic id, e.g. PE-MUX-001
+ cad_sheet: str | None = None # schematic sheet filename for plugin sync
+ cad_uuid: str | None = None # KiCad symbol/pin uuid
+ variant: str | None = None # DNP / ECO / assembly variant
+ # Finding engine (docs/motore-finding.md) — optional for legacy JSON.
+ facts: str = ""
+ requirement: str = ""
+ inference: str = ""
+ provenance: Literal["MANDATORY", "RECOMMENDED", "TYPICAL", "EXAMPLE"] | None = None
+ finding_class: Literal["RULE", "RISK", "REVIEW", "INFO"] | None = None
+ confidence: float | None = None
+ evidence_status: Literal["SUFFICIENT", "INSUFFICIENT"] | None = None
+ calculation: str = ""
+ assumptions: list[str] = []
+ action: str = ""
+ decision_id: str | None = None
+ suppressed: bool = False
+
+
+class ValidationReport(BaseModel):
+ """Full validation output."""
+ project: str
+ timestamp: str
+ findings: list[Finding]
+ summary: dict[str, int]
+ coverage: dict[str, list[str]] = {} # designator -> areas checked and found OK
+ review_errors: dict[str, str] = {} # designator -> error message for ICs whose review raised
+ not_reviewed: list[dict] = [] # [{"designator","reason"}] — ICs skipped (e.g. no datasheet PDF)
+
+
+class FindingComment(BaseModel):
+ """A comment on a finding, stored outside the ValidationReport model."""
+ comment_id: str
+ finding_id: str
+ user_id: str
+ user_name: str
+ text: str
+ mentions: list[str] = []
+ created_at: str
+
+
+# ---------------------------------------------------------------------------
+# Passive component pattern models
+# ---------------------------------------------------------------------------
+
+
+class PassiveFieldDef(BaseModel):
+ """One named field in a passive component part number."""
+ name: str
+ position: int
+ length: int
+ description: str
+ lookup: dict[str, str] = {}
+
+
+class ValueDecoder(BaseModel):
+ """How to decode the value field (resistance/capacitance) into a number.
+
+ letter_multipliers maps characters to power-of-10 exponents (int) or the
+ special string ``"decimal_point"`` for R-notation (e.g. 4R7 = 4.7 ohms).
+ """
+ type: str # "eia3_pf" | "eia4_ohm_conditional"
+ base_unit: str # "pF" | "ohm"
+ output_unit: str # "F" | "ohm"
+ letter_multipliers: dict[str, int | str] = {}
+ zero_code: str | None = None
+ conditional_on: dict | None = None
+
+
+class PassivePattern(BaseModel):
+ """Regex pattern + field decoders for a passive component family."""
+ manufacturer: str
+ series: str
+ component_type: ComponentType
+ component_subtype: str | None = None # dotted taxonomy path, e.g. "passive.capacitor.ceramic"
+ description: str
+ regex: str
+ fields: list[PassiveFieldDef]
+ value_decoder: ValueDecoder
+ example_mpns: list[str] = []
+ datasheet_key: str | None = None # library storage key for shared datasheet PDF
+
+ _validate_subtype = field_validator("component_subtype", mode="before")(
+ staticmethod(_check_subtype)
+ )
+
+
+class ResolvedPassive(BaseModel):
+ """Result of resolving a BOM MPN against a stored pattern."""
+ mpn: str
+ references: list[str]
+ component_type: ComponentType
+ component_subtype: str | None = None # dotted taxonomy path, e.g. "passive.resistor"
+
+ _validate_subtype = field_validator("component_subtype", mode="before")(
+ staticmethod(_check_subtype)
+ )
+ manufacturer: str
+ series: str
+ value: float
+ value_formatted: str
+ tolerance: str | None = None
+ package: str | None = None
+ voltage_rating: str | None = None
+ power_rating: str | None = None
+ dielectric: str | None = None
+ raw_fields: dict[str, str] = {}
+
+
+class LayoutPad(BaseModel):
+ number: str
+ x: float
+ y: float
+ net: str = ""
+ pinfunction: str = ""
+
+
+class LayoutFootprint(BaseModel):
+ reference: str
+ footprint: str = ""
+ x: float
+ y: float
+ layer: str = ""
+ pads: list[LayoutPad] = []
+ courtyard: list[tuple[float, float]] = []
+
+
+class LayoutSegment(BaseModel):
+ start: tuple[float, float]
+ end: tuple[float, float]
+ width: float = 0.0
+ layer: str = ""
+ net: str = ""
+
+
+class LayoutVia(BaseModel):
+ x: float
+ y: float
+ net: str = ""
+ drill: float | None = None
+
+
+class LayoutDielectric(BaseModel):
+ name: str
+ er: float
+ height_mm: float
+
+
+class LayoutStackup(BaseModel):
+ copper_layers: list[str]
+ dielectrics: list[LayoutDielectric]
+ copper_thickness_mm: float | None = None
+
+
+class LayoutZone(BaseModel):
+ net: str
+ layer: str
+ outlines: list[list[tuple[float, float]]] = []
+ keepout: bool = False
+ name: str = ""
+
+
+class LayoutGraph(BaseModel):
+ """Parsed `.kicad_pcb` geometry. Optional; schema validation does not require it."""
+ nets: dict[str, int] = {}
+ footprints: dict[str, LayoutFootprint] = {}
+ segments: list[LayoutSegment] = []
+ vias: list[LayoutVia] = []
+ stackup: LayoutStackup | None = None
+ zones: list[LayoutZone] = []
+
diff --git a/periscope/src/backend/periscopex/parsers.py b/periscope/src/backend/periscopex/parsers.py
new file mode 100644
index 0000000..9ad7ec2
--- /dev/null
+++ b/periscope/src/backend/periscopex/parsers.py
@@ -0,0 +1,316 @@
+"""Native Periscope overlay: PADS-PCB netlist and KiCad BOM parsers.
+
+PinScope original remains in dependency/.
+"""
+
+from __future__ import annotations
+
+import csv
+import re
+from pathlib import Path
+from typing import Literal
+
+NetlistFormat = Literal["pads", "edif", "kicad_xml", "kicad_sexp", "kicad_sch"]
+
+
+def parse_netlist(
+ path: str | Path,
+ known_refs: set[str] | None = None,
+) -> tuple[dict[str, str], dict[str, list[tuple[str, str]]]]:
+ """Parse a PADS-PCB ASCII netlist (.asc).
+
+ PADS-PCB allows reference designators containing spaces (e.g. ``CV GND``,
+ ``CAN BUS IN``, ``3.3V ACTIVE``). When ``known_refs`` is supplied (typically
+ from the BOM), tokens are greedily matched to the longest known designator
+ so multi-word refs parse correctly. Without ``known_refs`` the parser falls
+ back to single-word tokenisation.
+
+ Returns:
+ parts: {reference: footprint}
+ nets: {net_name: [(component_ref, pin_number), ...]}
+ """
+ text = Path(path).read_text()
+ lines = text.splitlines()
+
+ parts: dict[str, str] = {}
+ nets: dict[str, list[tuple[str, str]]] = {}
+
+ section = None
+ current_net: str | None = None
+
+ for raw_line in lines:
+ line = raw_line.strip()
+ if not line:
+ continue
+
+ # Section markers. PADS-PCB headers may carry trailing labels
+ # (e.g. "*PART* ITEMS" or "*MISC* MISCELLANEOUS PARAMETERS"
+ # from EasyEDA Pro), so match the marker prefix rather than the whole
+ # line. Unknown markers (anything starred that we don't recognise) are
+ # treated as section terminators — without this, EasyEDA Pro's *MISC*
+ # ATTRIBUTE VALUES block leaks into the net section and "Datasheet"
+ # URLs / footprint strings get misparsed as pin connections.
+ if line.startswith("*"):
+ if line.startswith("*SIGNAL*"):
+ pass # sub-marker within *NET*; handled in the net branch
+ elif line.startswith("*PART*"):
+ section = "part"
+ current_net = None
+ continue
+ elif line.startswith("*NET*"):
+ section = "net"
+ current_net = None
+ continue
+ elif line.startswith("*END*"):
+ break
+ else:
+ # *PADS-PCB*, *REMARK*, *MISC*, or any unrecognised marker
+ section = None
+ current_net = None
+ continue
+
+ if section == "part":
+ tokens = line.split()
+ ref, footprint = _parse_part_tokens(tokens, known_refs)
+ if ref:
+ parts[ref] = footprint
+
+ elif section == "net":
+ if line.startswith("*SIGNAL*"):
+ current_net = line.split("*SIGNAL*", 1)[1].strip()
+ if current_net not in nets:
+ nets[current_net] = []
+ elif current_net is not None:
+ # Pin entries: "REF.PIN REF.PIN ..." (REF may contain spaces)
+ nets[current_net].extend(_parse_pin_tokens(line.split(), known_refs))
+
+ # Some PADS-PCB exports omit the *PART* section entirely and ship only
+ # connectivity. Synthesize parts from refs seen in *SIGNAL* blocks so
+ # downstream validation and graph-building still work; footprints stay
+ # empty (the BOM is the source of truth for footprints anyway).
+ if not parts and nets:
+ for pins in nets.values():
+ for ref, _pin in pins:
+ parts.setdefault(ref, "")
+
+ return parts, nets
+
+
+def _parse_part_tokens(
+ tokens: list[str],
+ known_refs: set[str] | None,
+) -> tuple[str | None, str]:
+ """Split a *PART* line into (ref, footprint), respecting multi-word refs."""
+ if not tokens:
+ return None, ""
+
+ if known_refs:
+ # Greedy longest-prefix match against known refs
+ for n in range(min(len(tokens), 8), 0, -1):
+ candidate = " ".join(tokens[:n])
+ if candidate in known_refs:
+ return candidate, " ".join(tokens[n:])
+
+ # Fallback: single-word ref, rest is footprint
+ if len(tokens) >= 2:
+ return tokens[0], " ".join(tokens[1:])
+ return tokens[0], ""
+
+
+def _parse_pin_tokens(
+ tokens: list[str],
+ known_refs: set[str] | None,
+) -> list[tuple[str, str]]:
+ """Parse a *SIGNAL* pin line into (ref, pin) pairs.
+
+ Tokens terminate on a ``.`` — everything before (back to the previous
+ consumed position) is the ref, possibly with internal spaces.
+ """
+ pins: list[tuple[str, str]] = []
+ consumed = -1
+
+ for j, token in enumerate(tokens):
+ if j <= consumed or "." not in token:
+ continue
+
+ last_word, pin = token.rsplit(".", 1)
+
+ # Greedy longest match when known_refs is available
+ if known_refs:
+ matched_start: int | None = None
+ for start in range(consumed + 1, j + 1):
+ parts = tokens[start:j] + ([last_word] if last_word else [])
+ candidate = " ".join(parts)
+ if candidate and candidate in known_refs:
+ matched_start = start
+ break
+ if matched_start is not None:
+ ref = " ".join(
+ tokens[matched_start:j] + ([last_word] if last_word else [])
+ )
+ pins.append((ref, pin))
+ consumed = j
+ continue
+
+ # Fallback: single-word ref (original behaviour)
+ ref = last_word
+ pins.append((ref, pin))
+ consumed = j
+
+ return pins
+
+
+def detect_netlist_format(content: bytes | str) -> NetlistFormat:
+ """Sniff the first chunk of a netlist to decide the format.
+
+ EDIF starts with ``(edif``; KiCad XML with ``
tuple[dict[str, str], dict[str, list[tuple[str, str]]], NetlistFormat]:
+ """Auto-detect the netlist format and parse.
+
+ Returns ``(parts, nets, format)``. The ``parts`` and ``nets`` shapes match
+ :func:`parse_netlist`; downstream code (graph build, validation) doesn't
+ need to know which parser ran. ``known_refs`` is only relevant for PADS —
+ EDIF designators are unambiguous tokens. ``include_subdesigns`` is only
+ relevant for EDIF — it filters which ``&NNNN``-prefixed instances and
+ their nets land in the output (PADS netlists have no sub-design concept).
+ """
+ p = Path(path)
+ sample = p.read_bytes()[:2048]
+ fmt = detect_netlist_format(sample)
+ if fmt == "edif":
+ from backend.periscopex.parsers_edif import parse_edif_netlist
+ parts, nets = parse_edif_netlist(p, include_subdesigns=include_subdesigns)
+ elif fmt.startswith("kicad"):
+ from backend.periscopex.parsers_kicad import parse_kicad
+ parts, nets, _ = parse_kicad(p)
+ else:
+ parts, nets = parse_netlist(p, known_refs=known_refs)
+ return parts, nets, fmt
+
+
+def validate_netlist(parts: dict, nets: dict) -> list[str]:
+ """Sanity-check parsed netlist data. Returns a list of error strings (empty = valid)."""
+ errors: list[str] = []
+
+ if not parts:
+ errors.append(
+ "No components found — is this a PADS-PCB (.asc), EDIF (.edn), "
+ "or KiCad netlist / .kicad_sch?"
+ )
+ return errors # further checks are meaningless without parts
+
+ if not nets:
+ errors.append("No nets found — the connectivity section (*NET*) is missing or empty")
+ return errors
+
+ # At least some parts must appear in the net connections
+ refs_in_nets = {ref for pins in nets.values() for ref, _ in pins}
+ if not (set(parts) & refs_in_nets):
+ errors.append(
+ "No components are wired to any net — the connectivity section may be missing or malformed"
+ )
+
+ # Every real schematic has a ground net
+ gnd_names = {"GND", "AGND", "DGND", "PGND", "VSS", "0V"}
+ has_gnd = any(
+ n.upper() in gnd_names or n.upper().endswith("GND") or n.upper().startswith("GND")
+ for n in nets
+ )
+ if not has_gnd:
+ errors.append(
+ "No ground net found (expected GND, AGND, DGND, VSS, etc.) — "
+ "this may not be a complete schematic netlist"
+ )
+
+ return errors
+
+
+def parse_bom(
+ path: str | Path,
+ *,
+ reference_col: str = "Reference",
+ mpn_col: str = "Manufacturer Part Number",
+) -> dict[str, dict]:
+ """Parse a KiCad BOM CSV with grouped references.
+
+ Args:
+ path: Path to the BOM CSV file.
+ reference_col: Column name for reference designators.
+ mpn_col: Column name for manufacturer part numbers.
+
+ Returns:
+ {reference: {"value": str, "footprint": str, "mpn": str|None, "lcsc": str|None}}
+ One entry per individual reference (groups are expanded).
+ """
+ result: dict[str, dict] = {}
+ text = Path(path).read_text()
+ reader = csv.DictReader(text.splitlines())
+ colnames = {n.lower() for n in (reader.fieldnames or []) if n}
+ has_dnp_col = bool(colnames & {"dnp", "dni", "fitted", "populate"})
+ has_variant_col = bool(colnames & {"variant"})
+
+ for row in reader:
+ refs_raw = row.get(reference_col, "")
+ value = row.get("Value", "") or row.get("Comment", "")
+ footprint = row.get("Footprint", "")
+ mpn = (row.get(mpn_col, "") or "").strip() or None
+ lcsc = row.get("LCSC", "") or None
+ datasheet_url = (row.get("Datasheet", "") or "").strip() or None
+
+ # Expand grouped references: "C1,C2,C5" -> ["C1", "C2", "C5"]
+ refs = [r.strip() for r in refs_raw.split(",") if r.strip()]
+ # KiCad exports often leave Manufacturer Part Number empty and put
+ # the orderable code in Value (or PNM). Without this, U* never
+ # enter ic_mpns and review reports "no datasheet PDF".
+ if not mpn:
+ mpn = (row.get("PNM", "") or "").strip() or None
+ if not mpn and any(re.match(r"^U\d", r, re.I) for r in refs):
+ mpn = (value or "").strip() or None
+
+ dnp_raw = (row.get("DNP") or row.get("DNI") or "").strip().lower()
+ fitted_raw = (row.get("Fitted") or row.get("Populate") or "").strip().lower()
+ variant = (row.get("Variant") or row.get("variant") or "").strip() or None
+ is_dnp = dnp_raw in {"1", "y", "yes", "true", "dnp", "dni", "x"}
+ if not is_dnp and fitted_raw in {"0", "n", "no", "false"}:
+ is_dnp = True
+
+ for ref in refs:
+ entry = {
+ "value": value,
+ "footprint": footprint,
+ "mpn": mpn,
+ "lcsc": lcsc,
+ "datasheet_url": datasheet_url,
+ }
+ if has_dnp_col:
+ entry["dnp"] = is_dnp
+ if has_variant_col:
+ entry["variant"] = variant
+ result[ref] = entry
+
+ return result
diff --git a/periscope/src/backend/periscopex/parsers_edif.py b/periscope/src/backend/periscopex/parsers_edif.py
new file mode 100644
index 0000000..3308064
--- /dev/null
+++ b/periscope/src/backend/periscopex/parsers_edif.py
@@ -0,0 +1,470 @@
+"""Native Periscope overlay: EDIF 2.0.0 netlist parser.
+
+PinScope original remains in dependency/. Yields the same ``(parts, nets)``
+shape as :func:`parsers.parse_netlist`.
+
+Tested against xDX Designer's exporter. Other EDIF 2.0.0 exporters (OrCAD,
+Altium, KiCad, Eagle) will *probably* parse — the s-expression handling is
+generic and the EDIF instance/cell/net structure is standardised — but they
+have not been verified against real files.
+"""
+
+from __future__ import annotations
+
+import re
+from pathlib import Path
+from typing import Iterator
+
+
+# ---------------------------------------------------------------------------
+# Tokenizer + s-expression parser
+# ---------------------------------------------------------------------------
+
+
+class _Str(str):
+ """Marker subclass so quoted-string tokens are distinguishable from atoms.
+
+ Both atoms (e.g. ``viewRef``, ``&0441I3151``) and string values
+ (e.g. ``"U3"``, ``"GROUND"``) end up as Python ``str`` in the parsed
+ tree. EDIF rarely needs that distinction — string equality compares the
+ same way — but the marker is here in case future logic does.
+ """
+
+
+def _tokenize(text: str) -> Iterator[object]:
+ """Yield tokens: ``'('``, ``')'``, atom :class:`str`, or quoted :class:`_Str`."""
+ i, n = 0, len(text)
+ while i < n:
+ c = text[i]
+ if c.isspace():
+ i += 1
+ continue
+ if c == ";":
+ # EDIF doesn't really use comments, but tolerate them just in case
+ while i < n and text[i] != "\n":
+ i += 1
+ continue
+ if c in "()":
+ yield c
+ i += 1
+ continue
+ if c == '"':
+ j = i + 1
+ buf: list[str] = []
+ while j < n and text[j] != '"':
+ if text[j] == "\\" and j + 1 < n:
+ buf.append(text[j + 1])
+ j += 2
+ else:
+ buf.append(text[j])
+ j += 1
+ yield _Str("".join(buf))
+ i = j + 1
+ continue
+ j = i
+ while j < n and not text[j].isspace() and text[j] not in '()"':
+ j += 1
+ yield text[i:j]
+ i = j
+
+
+def _parse_sexp(tokens: list[object]) -> list:
+ """Build a nested list tree. Atoms / strings remain as ``str`` / ``_Str``."""
+ it = iter(tokens)
+
+ def parse_form() -> list:
+ result: list = []
+ for tok in it:
+ if tok == "(":
+ result.append(parse_form())
+ elif tok == ")":
+ return result
+ else:
+ result.append(tok)
+ return result # unterminated at EOF — return what we have
+
+ top: list = []
+ for tok in it:
+ if tok == "(":
+ top.append(parse_form())
+ elif tok == ")":
+ raise ValueError("EDIF: unexpected ')' at top level")
+ else:
+ top.append(tok)
+ return top
+
+
+# ---------------------------------------------------------------------------
+# Tree walkers
+# ---------------------------------------------------------------------------
+
+
+def _walk(node: object, head: str) -> Iterator[list]:
+ """Yield every nested list whose first element equals ``head``."""
+ if not isinstance(node, list):
+ return
+ if node and isinstance(node[0], str) and node[0] == head:
+ yield node
+ for child in node:
+ if isinstance(child, list):
+ yield from _walk(child, head)
+
+
+def _node_id(node: list) -> str | None:
+ """Return the identifying atom of ``( ...)``.
+
+ Handles ``( (rename &INTERNAL "display") ...)`` by returning
+ ``&INTERNAL`` — the form used elsewhere by ``cellRef`` / ``instanceRef``.
+ """
+ if len(node) < 2:
+ return None
+ second = node[1]
+ if isinstance(second, list) and len(second) >= 2 and second[0] == "rename":
+ return str(second[1])
+ if isinstance(second, str):
+ return str(second)
+ return None
+
+
+def _direct_property(node: list, prop_name: str) -> str | None:
+ """Return the string value of a ``(property NAME (string "X") ...)`` child.
+
+ Only looks at direct children of ``node`` — does not recurse into nested
+ forms — so it can be called on an ``instance`` without picking up
+ properties tucked inside ``portInstance`` blocks.
+ """
+ for child in node:
+ if not (isinstance(child, list) and len(child) >= 2 and child[0] == "property"):
+ continue
+ name_node = child[1]
+ if isinstance(name_node, list) and name_node and name_node[0] == "rename":
+ actual = str(name_node[1]) if len(name_node) >= 2 else ""
+ elif isinstance(name_node, str):
+ actual = str(name_node)
+ else:
+ continue
+ if actual != prop_name:
+ continue
+ for elem in child[2:]:
+ if isinstance(elem, list) and len(elem) >= 2 and elem[0] == "string":
+ return str(elem[1])
+ return None
+
+
+# ---------------------------------------------------------------------------
+# Stage extractors
+# ---------------------------------------------------------------------------
+
+
+def _build_cell_library(tree: list) -> dict[tuple[str, str], dict[str, str | None]]:
+ """Build ``(library_name, cell_id) -> {port_name: pin_type}``.
+
+ ``pin_type`` is ``"GROUND"`` (or any other ``Pin_Type`` property value) when
+ the cell tagged the port; ``None`` when no Pin_Type property is present.
+ Used to detect which nets are ground.
+ """
+ cells: dict[tuple[str, str], dict[str, str | None]] = {}
+ for lib in _walk(tree, "library"):
+ if len(lib) < 2:
+ continue
+ lib_name = str(lib[1])
+ for cell in _walk(lib, "cell"):
+ cell_id = _node_id(cell)
+ if not cell_id:
+ continue
+ port_map: dict[str, str | None] = {}
+ for port in _walk(cell, "port"):
+ if len(port) < 2:
+ continue
+ port_name = str(port[1])
+ port_map[port_name] = _direct_property(port, "Pin_Type")
+ cells[(lib_name, cell_id)] = port_map
+ return cells
+
+
+def _find_cell_ref(node: list) -> tuple[str, str] | None:
+ """From an ``(instance ...)`` form, return ``(library_name, cell_id)`` from
+ its ``(viewRef VIEW (cellRef CELL (libraryRef LIB)))`` triple."""
+ for child in node:
+ if not (isinstance(child, list) and child and child[0] == "viewRef"):
+ continue
+ for sub in child[1:]:
+ if isinstance(sub, list) and len(sub) >= 2 and sub[0] == "cellRef":
+ cell_id = str(sub[1])
+ lib_name = ""
+ for sub2 in sub[2:]:
+ if isinstance(sub2, list) and len(sub2) >= 2 and sub2[0] == "libraryRef":
+ lib_name = str(sub2[1])
+ break
+ return (lib_name, cell_id)
+ return None
+
+
+_SUBDESIGN_PREFIX = re.compile(r"^(&\d+)[IN]\d+")
+
+
+def _subdesign_id(internal_id: str | None) -> str | None:
+ """Extract the sub-design prefix from an EDIF instance or net ID.
+
+ Siemens xDX Designer emits internal IDs like ``&0441I2234`` (instance) or
+ ``&0441N2250`` (net), where ``&0441`` identifies the sub-design /
+ schematic view the symbol belongs to. Different sub-designs in one file
+ get different numeric prefixes; back-annotation, contents, and viewMap
+ all reuse the same prefix per design.
+
+ Returns ``None`` when the ID doesn't match the prefix scheme (bare-named
+ cells, named nets like ``+5V``, or exports from non-xDX tools). The
+ parser treats ``None`` as "shared / no sub-design" and includes those
+ forms in every selection.
+ """
+ if not internal_id:
+ return None
+ m = _SUBDESIGN_PREFIX.match(internal_id)
+ return m.group(1) if m else None
+
+
+def _build_instance_map(tree: list) -> dict[str, dict]:
+ """Walk every ``(instance ...)`` form. Skip back-annotation refs in viewMap.
+
+ Each entry: ``{cell_ref, port_pins, inline_designator, footprint, subdesign_id}``.
+ """
+ instances: dict[str, dict] = {}
+ for inst in _walk(tree, "instance"):
+ inst_id = _node_id(inst)
+ if not inst_id:
+ continue
+
+ cell_ref = _find_cell_ref(inst)
+
+ port_pins: dict[str, str] = {}
+ inline_des: str | None = None
+ for child in inst:
+ if not isinstance(child, list) or not child:
+ continue
+ if child[0] == "portInstance" and len(child) >= 2:
+ port_name = str(child[1])
+ for sub in child[2:]:
+ if isinstance(sub, list) and len(sub) >= 2 and sub[0] == "designator":
+ port_pins[port_name] = str(sub[1])
+ break
+ elif child[0] == "designator" and len(child) >= 2 and inline_des is None:
+ inline_des = str(child[1])
+
+ instances[inst_id] = {
+ "cell_ref": cell_ref,
+ "port_pins": port_pins,
+ "inline_designator": inline_des,
+ "footprint": _direct_property(inst, "Cell_Name") or "",
+ "subdesign_id": _subdesign_id(inst_id),
+ }
+ return instances
+
+
+def _build_back_annotation(tree: list) -> dict[str, str]:
+ """``instance_id -> real_designator`` from ``viewMap.instanceBackAnnotate``."""
+ annotations: dict[str, str] = {}
+ for ann in _walk(tree, "instanceBackAnnotate"):
+ inst_id: str | None = None
+ des: str | None = None
+ for child in ann[1:]:
+ if not isinstance(child, list) or len(child) < 2:
+ continue
+ if child[0] == "instanceRef":
+ inst_id = str(child[1])
+ elif child[0] == "designator":
+ des = str(child[1])
+ if inst_id and des:
+ annotations[inst_id] = des
+ return annotations
+
+
+def _is_template_designator(des: str) -> bool:
+ """xDX exports unconfigured instances with templates like ``R?`` / ``U?``."""
+ return des.endswith("?")
+
+
+def _resolve_designators(
+ instances: dict[str, dict], back_anno: dict[str, str]
+) -> dict[str, str]:
+ """For each instance, pick the real designator. Drop template-only ones."""
+ resolved: dict[str, str] = {}
+ for inst_id, inst in instances.items():
+ inline = inst["inline_designator"]
+ annotated = back_anno.get(inst_id)
+ if inline and not _is_template_designator(inline):
+ resolved[inst_id] = inline
+ elif annotated and not _is_template_designator(annotated):
+ resolved[inst_id] = annotated
+ # else: unconfigured library symbol — skip
+ return resolved
+
+
+def _extract_nets(
+ tree: list,
+ instances: dict[str, dict],
+ designators: dict[str, str],
+ cell_lib: dict[tuple[str, str], dict[str, str | None]],
+ include_subdesigns: set[str] | None = None,
+) -> dict[str, list[tuple[str, str]]]:
+ """Walk every ``(net ...)`` form. Rename ground-touching nets to ``GND``.
+
+ When ``include_subdesigns`` is supplied, endpoints belonging to
+ excluded sub-designs are dropped. A net is kept iff it has at least one
+ surviving endpoint — bare-named nets (no sub-design prefix) survive as
+ long as any of their referenced instances does.
+ """
+ nets: dict[str, list[tuple[str, str]]] = {}
+ for net in _walk(tree, "net"):
+ if len(net) < 2:
+ continue
+ name_node = net[1]
+ if isinstance(name_node, list) and len(name_node) >= 3 and name_node[0] == "rename":
+ net_name = str(name_node[2])
+ elif isinstance(name_node, str):
+ net_name = str(name_node)
+ else:
+ continue
+
+ connections: list[tuple[str, str]] = []
+ touches_ground = False
+ for child in net[1:]:
+ if not (isinstance(child, list) and child and child[0] == "joined"):
+ continue
+ for ref in child[1:]:
+ if not (isinstance(ref, list) and len(ref) >= 2 and ref[0] == "portRef"):
+ continue
+ port_name = str(ref[1])
+ inst_id: str | None = None
+ for sub in ref[2:]:
+ if isinstance(sub, list) and len(sub) >= 2 and sub[0] == "instanceRef":
+ inst_id = str(sub[1])
+ break
+ if not inst_id or inst_id not in instances:
+ continue
+ inst = instances[inst_id]
+ if include_subdesigns is not None:
+ if inst["subdesign_id"] not in include_subdesigns:
+ continue
+ pin = inst["port_pins"].get(port_name)
+ des = designators.get(inst_id)
+ if not pin or not des:
+ continue
+ if inst["cell_ref"]:
+ port_map = cell_lib.get(inst["cell_ref"], {})
+ if port_map.get(port_name) == "GROUND":
+ touches_ground = True
+ connections.append((des, pin))
+
+ if not connections:
+ continue
+ final_name = "GND" if touches_ground else net_name
+ nets.setdefault(final_name, []).extend(connections)
+ return nets
+
+
+# ---------------------------------------------------------------------------
+# Public entry point
+# ---------------------------------------------------------------------------
+
+
+def _parse_tree(path: str | Path) -> list:
+ text = Path(path).read_text(encoding="utf-8", errors="replace")
+ return _parse_sexp(list(_tokenize(text)))
+
+
+def parse_edif_netlist(
+ path: str | Path,
+ *,
+ include_subdesigns: set[str] | None = None,
+) -> tuple[dict[str, str], dict[str, list[tuple[str, str]]]]:
+ """Parse a Siemens xDX Designer EDIF 2.0.0 netlist (``.edn``).
+
+ Args:
+ path: file to parse.
+ include_subdesigns: when supplied, restrict the output to instances
+ whose ``&NNNN`` sub-design prefix is in this set. Instances with
+ no prefix (bare-named cells) are always kept. ``None`` (default)
+ includes every sub-design — same behavior as before this flag
+ existed.
+
+ Returns:
+ parts: ``{reference: footprint}`` (footprint from the instance's
+ ``Cell_Name`` property — typically a package size like ``"0402"``)
+ nets: ``{net_name: [(component_ref, pin_number), ...]}``
+
+ Ground nets are renamed to ``"GND"`` based on ``Pin_Type=GROUND`` port
+ tags in the cell library; if no port tags ground (rare), net names stay
+ as the EDIF-generated ``$NN…`` strings and downstream validation will
+ surface the missing ground.
+ """
+ tree = _parse_tree(path)
+
+ cell_lib = _build_cell_library(tree)
+ instances = _build_instance_map(tree)
+ back_anno = _build_back_annotation(tree)
+ designators = _resolve_designators(instances, back_anno)
+
+ if include_subdesigns is not None:
+ # Drop excluded instances before nets are walked. Instances with
+ # subdesign_id=None (bare-named, no prefix) are always kept — they're
+ # shared between sub-designs in the xDX export and dropping them
+ # would orphan otherwise-included nets.
+ designators = {
+ iid: des
+ for iid, des in designators.items()
+ if instances[iid]["subdesign_id"] is None
+ or instances[iid]["subdesign_id"] in include_subdesigns
+ }
+
+ nets = _extract_nets(
+ tree, instances, designators, cell_lib,
+ include_subdesigns=include_subdesigns,
+ )
+
+ parts: dict[str, str] = {}
+ for inst_id, des in designators.items():
+ parts[des] = instances[inst_id]["footprint"]
+
+ return parts, nets
+
+
+def list_edif_subdesigns(path: str | Path) -> list[dict]:
+ """Return one entry per sub-design found in the file.
+
+ Each entry: ``{"id": "&0441", "instance_count": 21,
+ "designators": ["C1", "C2", ...]}``. Sub-designs are identified by the
+ ``&NNNN`` prefix on EDIF instance IDs; instances with no prefix (bare
+ cells, rare in xDX exports) are bundled under ``"id": None`` and are
+ always included regardless of the user's selection.
+
+ Designators are sorted naturally (R1 before R10) within each sub-design;
+ sub-designs themselves are sorted by their first BOM-style designator so
+ output is deterministic across runs.
+ """
+ tree = _parse_tree(path)
+ instances = _build_instance_map(tree)
+ back_anno = _build_back_annotation(tree)
+ designators = _resolve_designators(instances, back_anno)
+
+ by_sub: dict[str | None, list[str]] = {}
+ for iid, des in designators.items():
+ sub = instances[iid]["subdesign_id"]
+ by_sub.setdefault(sub, []).append(des)
+
+ def _key(des: str) -> tuple:
+ # Sort R1 before R10 — split on the first digit run.
+ head = des.rstrip("0123456789")
+ tail = des[len(head):]
+ return (head, int(tail) if tail.isdigit() else 0)
+
+ out: list[dict] = []
+ for sub, dlist in by_sub.items():
+ dlist.sort(key=_key)
+ out.append({
+ "id": sub,
+ "instance_count": len(dlist),
+ "designators": dlist,
+ })
+
+ out.sort(key=lambda e: (e["designators"][0] if e["designators"] else "", e["id"] or ""))
+ return out
diff --git a/periscope/src/backend/periscopex/taxonomy.py b/periscope/src/backend/periscopex/taxonomy.py
new file mode 100644
index 0000000..4332095
--- /dev/null
+++ b/periscope/src/backend/periscopex/taxonomy.py
@@ -0,0 +1,319 @@
+"""Native Periscope overlay: living component taxonomy.
+
+PinScope original remains in dependency/. JSON files stay under
+``periscope/dependency/taxonomy/`` (Docker: ``/app/taxonomy``).
+
+Storage: one JSON file per top-level type in ``taxonomy/``.
+Each file is a self-contained document that maps 1:1 to a Firestore
+document, so only the relevant branch needs to be fetched/injected
+into extraction prompts.
+
+::
+
+ taxonomy/
+ ├── ic.json # all IC subtypes
+ ├── passive.json # all passive subtypes
+ ├── discrete.json # diodes, transistors, LEDs
+ ├── connector.json
+ ├── crystal.json
+ └── ...
+"""
+
+from __future__ import annotations
+
+import json
+import re
+from pathlib import Path
+
+from backend.repo_paths import taxonomy_dir as _repo_taxonomy_dir
+
+_app_taxonomy = Path("/app/taxonomy")
+TAXONOMY_DIR = _app_taxonomy if _app_taxonomy.is_dir() else _repo_taxonomy_dir()
+
+# Reference-designator prefix -> taxonomy top-level type.
+# Used by extraction skills: "I see 'U' so I only need the ic branch."
+REF_PREFIX_TO_TYPE: dict[str, str] = {
+ "U": "ic",
+ "IC": "ic",
+ "R": "passive",
+ "C": "passive",
+ "L": "passive",
+ "FB": "passive",
+ "J": "connector",
+ "X": "crystal",
+ "Y": "crystal",
+ "D": "discrete",
+ "LED": "discrete",
+ "Q": "discrete",
+ "T": "transformer",
+ "F": "fuse",
+ "SW": "switch",
+ "TP": "test_point",
+ "FM": "fiducial",
+ "MH": "mechanical",
+}
+
+# Canonical format for dotted subtype keys.
+SUBTYPE_PATTERN = re.compile(r"^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*$")
+
+# All valid top-level taxonomy types (derived from ref-prefix mapping).
+KNOWN_TYPES: frozenset[str] = frozenset(REF_PREFIX_TO_TYPE.values())
+
+
+def validate_subtype(value: str) -> str:
+ """Validate and normalize a component_subtype string.
+
+ Lowercases, replaces hyphens/spaces with underscores, then checks
+ the dotted format and that the top-level segment is a known type.
+
+ Returns the normalized value. Raises ``ValueError`` if invalid.
+ """
+ v = value.strip().lower().replace("-", "_").replace(" ", "_")
+ if not SUBTYPE_PATTERN.match(v):
+ raise ValueError(
+ f"Invalid component_subtype format: {value!r}. "
+ f"Expected dotted lowercase path like 'ic.mcu' or 'passive.resistor'"
+ )
+ top = v.split(".")[0]
+ if top not in KNOWN_TYPES:
+ raise ValueError(
+ f"Unknown top-level taxonomy type: {top!r} (from {value!r}). "
+ f"Known types: {sorted(KNOWN_TYPES)}"
+ )
+ return v
+
+
+def type_for_ref(ref: str) -> str | None:
+ """Map a reference designator (e.g. 'U3', 'C12') to a taxonomy type."""
+ prefix = re.match(r"^[A-Za-z]+", ref)
+ if not prefix:
+ return None
+ return REF_PREFIX_TO_TYPE.get(prefix.group().upper())
+
+
+# ---------------------------------------------------------------------------
+# Loading
+# ---------------------------------------------------------------------------
+
+
+def _load_type_file(top_type: str, directory: Path = TAXONOMY_DIR) -> dict:
+ """Load a single type file, returning its raw JSON."""
+ path = directory / f"{top_type}.json"
+ if not path.exists():
+ return {"type": top_type, "subtypes": {}}
+ return json.loads(path.read_text())
+
+
+def _save_type_file(top_type: str, data: dict, directory: Path = TAXONOMY_DIR) -> None:
+ """Write a type file back to disk."""
+ directory.mkdir(parents=True, exist_ok=True)
+ path = directory / f"{top_type}.json"
+ path.write_text(json.dumps(data, indent=2) + "\n")
+
+
+def load_subtypes(
+ top_type: str | None = None,
+ directory: Path = TAXONOMY_DIR,
+) -> dict[str, dict]:
+ """Return subtypes as ``{dotted_key: {description, example_mpn?}}``.
+
+ If *top_type* is given (e.g. ``"ic"``), only that file is loaded —
+ keeping prompt injection small. If ``None``, all files are merged.
+ """
+ if top_type is not None:
+ return dict(_load_type_file(top_type, directory).get("subtypes", {}))
+
+ merged: dict[str, dict] = {}
+ for f in sorted(directory.glob("*.json")):
+ data = json.loads(f.read_text())
+ merged.update(data.get("subtypes", {}))
+ return merged
+
+
+def list_subtypes(
+ prefix: str | None = None,
+ directory: Path = TAXONOMY_DIR,
+) -> list[str]:
+ """List subtype keys, optionally filtered by dotted prefix.
+
+ Efficient: if *prefix* starts with a known top-level type, only that
+ single file is loaded.
+
+ Examples::
+
+ list_subtypes() # all subtypes (loads every file)
+ list_subtypes("ic") # only ic.json loaded
+ list_subtypes("ic.power") # only ic.json loaded, filtered
+ list_subtypes("passive") # only passive.json loaded
+ """
+ # Determine which top-level type file to load
+ top_type: str | None = None
+ if prefix is not None:
+ top_type = prefix.split(".")[0]
+
+ subtypes = load_subtypes(top_type, directory)
+
+ if prefix is None:
+ return sorted(subtypes.keys())
+
+ prefix_dot = prefix if prefix.endswith(".") else prefix + "."
+ return sorted(k for k in subtypes if k == prefix or k.startswith(prefix_dot))
+
+
+def get_subtype(key: str, directory: Path = TAXONOMY_DIR) -> dict | None:
+ """Get a single subtype entry by its dotted key, or None."""
+ top_type = key.split(".")[0]
+ subtypes = load_subtypes(top_type, directory)
+ return subtypes.get(key)
+
+
+def set_type_specs(
+ top_type: str,
+ specs: list[dict],
+ directory: Path = TAXONOMY_DIR,
+) -> None:
+ """Set type-level specs on a taxonomy file."""
+ data = _load_type_file(top_type, directory)
+ data["specs"] = specs
+ _save_type_file(top_type, data, directory)
+
+
+def set_extra_specs(
+ subtype_key: str,
+ extra_specs: list[dict],
+ directory: Path = TAXONOMY_DIR,
+) -> None:
+ """Set extra_specs on an existing subtype entry."""
+ top_type = subtype_key.split(".")[0]
+ data = _load_type_file(top_type, directory)
+ subtypes = data.get("subtypes", {})
+ if subtype_key not in subtypes:
+ return
+ subtypes[subtype_key]["extra_specs"] = extra_specs
+ _save_type_file(top_type, data, directory)
+
+
+def has_specs(top_type: str, directory: Path = TAXONOMY_DIR) -> bool:
+ """Check if a taxonomy type has any specs defined (type-level or extra)."""
+ data = _load_type_file(top_type, directory)
+ if data.get("specs"):
+ return True
+ for entry in data.get("subtypes", {}).values():
+ if entry.get("extra_specs"):
+ return True
+ return False
+
+
+def add_subtype(
+ key: str,
+ description: str,
+ example_mpn: str | None = None,
+ directory: Path = TAXONOMY_DIR,
+) -> None:
+ """Add a new subtype. Creates the type file if needed. No-op if exists."""
+ key = validate_subtype(key)
+ top_type = key.split(".")[0]
+ data = _load_type_file(top_type, directory)
+ subtypes = data.setdefault("subtypes", {})
+
+ if key in subtypes:
+ return
+
+ entry: dict[str, str] = {"description": description}
+ if example_mpn:
+ entry["example_mpn"] = example_mpn
+ subtypes[key] = entry
+
+ data["type"] = top_type
+ _save_type_file(top_type, data, directory)
+
+
+def get_specs_schema(
+ top_type: str,
+ subtype_key: str | None = None,
+ directory: Path = TAXONOMY_DIR,
+) -> list[dict]:
+ """Return merged specs list: type-level ``specs`` + subtype ``extra_specs``."""
+ data = _load_type_file(top_type, directory)
+ specs = list(data.get("specs", []))
+ if subtype_key:
+ entry = data.get("subtypes", {}).get(subtype_key, {})
+ specs.extend(entry.get("extra_specs", []))
+ return specs
+
+
+def format_specs_for_prompt(top_type: str, directory: Path = TAXONOMY_DIR) -> str:
+ """Format type-level + all subtype extra_specs as prompt text.
+
+ Includes all possible parameters across subtypes so the extraction
+ skill knows the full set of fields it might encounter.
+ """
+ data = _load_type_file(top_type, directory)
+ base_specs = data.get("specs", [])
+ # Collect all extra_specs across subtypes (deduplicate by name)
+ all_extra: dict[str, dict] = {}
+ for entry in data.get("subtypes", {}).values():
+ for s in entry.get("extra_specs", []):
+ all_extra[s["name"]] = s
+ all_specs = list(base_specs) + list(all_extra.values())
+ if not all_specs:
+ return ""
+ lines = [
+ "PARAMETERS TO EXTRACT (include all that are relevant to this component):",
+ "",
+ "Use SPICE multiplier prefixes for values: "
+ "T=1e12, G=1e9, M=1e6, k=1e3, m=1e-3, u=1e-6, n=1e-9, p=1e-12.",
+ "Examples: 30V, 240mV, 500mA, 47mohm, 18pF, 8MHz, 10nC.",
+ "Always include the unit with the multiplier in the value string.",
+ "",
+ ]
+ for s in all_specs:
+ req = " (REQUIRED)" if s.get("required") else ""
+ unit = f" [{s['unit']}]" if s.get("unit") else ""
+ lines.append(f"- {s['name']}{unit}: {s['description']}{req}")
+ return "\n".join(lines)
+
+
+def format_for_prompt(top_type: str, directory: Path = TAXONOMY_DIR) -> str:
+ """Format a type's subtypes as a compact string for LLM prompt injection.
+
+ Returns something like::
+
+ ic.mcu — Microcontroller (e.g. MSPM0G3507SPTR)
+ ic.power.ldo — Low-dropout voltage regulator (e.g. SPX3819M5-L-3-3)
+ ic.power.switching_regulator — Switching voltage regulator (buck, boost, buck-boost)
+ ...
+ """
+ subtypes = load_subtypes(top_type, directory)
+ lines: list[str] = []
+ for key in sorted(subtypes):
+ entry = subtypes[key]
+ line = f"{key} — {entry['description']}"
+ if "example_mpn" in entry:
+ line += f" (e.g. {entry['example_mpn']})"
+ lines.append(line)
+ return "\n".join(lines)
+
+
+# ---------------------------------------------------------------------------
+# Simple types (taxonomy-driven specs extraction via PDF)
+# ---------------------------------------------------------------------------
+
+
+def _compute_simple_types(directory: Path = TAXONOMY_DIR) -> frozenset[str]:
+ """Types that have a ``specs`` schema and use PDF-based extraction.
+
+ Excludes ``ic`` (pintable + rules) and ``passive`` (pattern-based).
+ """
+ result: set[str] = set()
+ if not directory.is_dir():
+ return frozenset(result)
+ for f in directory.glob("*.json"):
+ data = json.loads(f.read_text())
+ t = data.get("type", "")
+ if t not in ("ic", "passive") and data.get("specs"):
+ result.add(t)
+ return frozenset(result)
+
+
+SIMPLE_TYPES: frozenset[str] = _compute_simple_types()
diff --git a/periscope/src/docs/development/PINSCOPE_INDEPENDENCE_PLAN.md b/periscope/src/docs/development/PINSCOPE_INDEPENDENCE_PLAN.md
index 42cc9fa..ad49047 100644
--- a/periscope/src/docs/development/PINSCOPE_INDEPENDENCE_PLAN.md
+++ b/periscope/src/docs/development/PINSCOPE_INDEPENDENCE_PLAN.md
@@ -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` — PCB/placement non importano `pipeline.py`. Parsers/graph still dependency. Fork non staccato.
+**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.
**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`). **Mai** empty-delete. Parsers/graph (C5/E) e auto-place fuori scope. AGPL resta.
+**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.
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,7 +31,7 @@ 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` | AGPL-3.0 del fork (blob `LICENSE` identico a upstream) |
+| 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 |
| UI OSS / marketing shell | `periscope/dependency/frontend/` (UPSTREAM/DERIVED); file nativi in `periscope/src/frontend/` con symlink nel recinto | AGPL |
@@ -61,6 +61,7 @@ Trattare come **una dipendenza in-tree**, non come prodotto Periscope:
| LLM DeepSeek | `periscope/src/backend/services/llm/deepseek_provider.py`, `local_skill.py`, `pdf_ingest.py` | NEW |
| 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 |
| 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 |
@@ -291,7 +292,7 @@ Solo se l’obiettivo diventa *opera senza codice AGPL PinScope*. Sostituire BOM
9. **Loop `validate.py` / `validation_tools`** — reviewer nativo (C)
10. **PCB/schema smettono di chiamare `_parse_review` PinScope** (C4)
11. **UI FindingCard/wizard** (D)
-12. **Parsers/graph** solo se si vuole uscire da AGPL PinScope (E)
+12. **Parsers/graph overlay** — **2.42.0** src overlay (not a CAD rewrite). AGPL recinto resta su disco. Relicenza non-AGPL ancora **E** / XL.
13. **Stacco fork** — legale, fuori piano
---
diff --git a/periscope/src/frontend/public/brand/periscope-mark-1024.png b/periscope/src/frontend/public/brand/periscope-mark-1024.png
new file mode 100644
index 0000000..f1e8f79
Binary files /dev/null and b/periscope/src/frontend/public/brand/periscope-mark-1024.png differ
diff --git a/periscope/src/frontend/public/brand/periscope-mark.svg b/periscope/src/frontend/public/brand/periscope-mark.svg
new file mode 100644
index 0000000..d30f830
--- /dev/null
+++ b/periscope/src/frontend/public/brand/periscope-mark.svg
@@ -0,0 +1,12 @@
+
diff --git a/periscope/src/frontend/public/favicon_io/android-chrome-192x192.png b/periscope/src/frontend/public/favicon_io/android-chrome-192x192.png
new file mode 100644
index 0000000..1ad64f5
Binary files /dev/null and b/periscope/src/frontend/public/favicon_io/android-chrome-192x192.png differ
diff --git a/periscope/src/frontend/public/favicon_io/android-chrome-512x512.png b/periscope/src/frontend/public/favicon_io/android-chrome-512x512.png
new file mode 100644
index 0000000..4a6b078
Binary files /dev/null and b/periscope/src/frontend/public/favicon_io/android-chrome-512x512.png differ
diff --git a/periscope/src/frontend/public/favicon_io/apple-touch-icon.png b/periscope/src/frontend/public/favicon_io/apple-touch-icon.png
new file mode 100644
index 0000000..238fae1
Binary files /dev/null and b/periscope/src/frontend/public/favicon_io/apple-touch-icon.png differ
diff --git a/periscope/src/frontend/public/favicon_io/favicon-16x16.png b/periscope/src/frontend/public/favicon_io/favicon-16x16.png
new file mode 100644
index 0000000..b40f4f4
Binary files /dev/null and b/periscope/src/frontend/public/favicon_io/favicon-16x16.png differ
diff --git a/periscope/src/frontend/public/favicon_io/favicon-32x32.png b/periscope/src/frontend/public/favicon_io/favicon-32x32.png
new file mode 100644
index 0000000..711870d
Binary files /dev/null and b/periscope/src/frontend/public/favicon_io/favicon-32x32.png differ
diff --git a/periscope/src/frontend/public/favicon_io/favicon.ico b/periscope/src/frontend/public/favicon_io/favicon.ico
new file mode 100644
index 0000000..bfbd03d
Binary files /dev/null and b/periscope/src/frontend/public/favicon_io/favicon.ico differ
diff --git a/periscope/src/frontend/public/favicon_io/site.webmanifest b/periscope/src/frontend/public/favicon_io/site.webmanifest
new file mode 100644
index 0000000..2f0d038
--- /dev/null
+++ b/periscope/src/frontend/public/favicon_io/site.webmanifest
@@ -0,0 +1,19 @@
+{
+ "name": "Periscope",
+ "short_name": "Periscope",
+ "icons": [
+ {
+ "src": "/favicon_io/android-chrome-192x192.png",
+ "sizes": "192x192",
+ "type": "image/png"
+ },
+ {
+ "src": "/favicon_io/android-chrome-512x512.png",
+ "sizes": "512x512",
+ "type": "image/png"
+ }
+ ],
+ "theme_color": "#3B82F6",
+ "background_color": "#0B1220",
+ "display": "standalone"
+}
diff --git a/periscope/src/frontend/src/components/brand/periscope-mark.tsx b/periscope/src/frontend/src/components/brand/periscope-mark.tsx
new file mode 100644
index 0000000..fdda7c7
--- /dev/null
+++ b/periscope/src/frontend/src/components/brand/periscope-mark.tsx
@@ -0,0 +1,21 @@
+import { cn } from "@/lib/utils";
+
+/** Header / favicon-sized Periscope mark. Default matches lucide h-5 w-5 (20px). */
+export function PeriscopeMark({
+ className,
+ size = 20,
+}: {
+ className?: string;
+ size?: number;
+}) {
+ return (
+ // eslint-disable-next-line @next/next/no-img-element
+
+ );
+}
diff --git a/tests/test_native_graph_overlay.py b/tests/test_native_graph_overlay.py
new file mode 100644
index 0000000..1b7221f
--- /dev/null
+++ b/tests/test_native_graph_overlay.py
@@ -0,0 +1,36 @@
+"""Native overlay: graph, parsers, models, taxonomy resolve from periscope/src."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+import backend.periscopex.graph as graph
+import backend.periscopex.models as models
+import backend.periscopex.parsers as parsers
+import backend.periscopex.parsers_edif as parsers_edif
+import backend.periscopex.taxonomy as taxonomy
+
+
+def _src_file(mod) -> Path:
+ return Path(mod.__file__).resolve()
+
+
+def test_graph_parsers_models_taxonomy_load_from_src():
+ root = Path(__file__).resolve().parents[1]
+ src = (root / "periscope" / "src" / "backend" / "periscopex").resolve()
+ for mod, name in (
+ (graph, "graph.py"),
+ (models, "models.py"),
+ (parsers, "parsers.py"),
+ (parsers_edif, "parsers_edif.py"),
+ (taxonomy, "taxonomy.py"),
+ ):
+ path = _src_file(mod)
+ assert path == src / name, path
+ assert "Native Periscope overlay" in path.read_text(encoding="utf-8")[:400]
+
+
+def test_taxonomy_dir_points_at_inherited_json():
+ ic = taxonomy.TAXONOMY_DIR / "ic.json"
+ assert ic.is_file(), taxonomy.TAXONOMY_DIR
+ assert "src/taxonomy" not in str(taxonomy.TAXONOMY_DIR)