diff --git a/periscope/src/backend/periscopex/models.py b/periscope/src/backend/periscopex/models.py new file mode 100644 index 0000000..bc0d90c --- /dev/null +++ b/periscope/src/backend/periscopex/models.py @@ -0,0 +1,534 @@ +"""Periscope schematic and layout data contracts. + +Public types used by graph build, review, and PCB exam. Layout primitives are +distinct types: pad (component pin land), via (drill, never a pin), segment +(track), zone (pour/keepout). Do not coerce one into another. +""" + +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): + """Component pin land. Not a via, track, or zone.""" + 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): + """Copper track segment. Not a pad, via, or zone.""" + start: tuple[float, float] + end: tuple[float, float] + width: float = 0.0 + layer: str = "" + net: str = "" + + +class LayoutVia(BaseModel): + """Board or footprint via. Has drill; has no pin number.""" + 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): + """Copper pour or keepout outline. Not a track or via.""" + 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/tests/test_periscope_models_rewrite.py b/tests/test_periscope_models_rewrite.py new file mode 100644 index 0000000..7f84fb6 --- /dev/null +++ b/tests/test_periscope_models_rewrite.py @@ -0,0 +1,44 @@ +"""Periscope models load from src; layout primitives stay disjoint.""" + +from __future__ import annotations + +from pathlib import Path + +import backend.periscopex.models as models +from backend.periscopex.models import ( + LayoutPad, + LayoutSegment, + LayoutVia, + LayoutZone, +) + + +def test_models_module_is_src(): + path = Path(models.__file__).resolve() + assert path.parts[-5:] == ("periscope", "src", "backend", "periscopex", "models.py") + text = path.read_text(encoding="utf-8") + assert "Native Periscope overlay" not in text + assert "pad (component pin land)" in text + + +def test_pad_via_track_zone_are_distinct_types(): + pad = LayoutPad(number="1", x=0.0, y=0.0, net="GND") + via = LayoutVia(x=0.0, y=0.0, net="GND", drill=0.3) + track = LayoutSegment(start=(0.0, 0.0), end=(1.0, 0.0), net="GND") + zone = LayoutZone(net="GND", layer="F.Cu") + kinds = {type(pad), type(via), type(track), type(zone)} + assert len(kinds) == 4 + assert not hasattr(via, "number") + assert hasattr(pad, "number") + assert not hasattr(track, "drill") + assert not hasattr(zone, "drill") + assert pad.model_dump() != via.model_dump() + + +def test_via_is_not_a_pad_even_on_same_net_and_xy(): + """Negative: same net and coordinates do not make a via a pad.""" + pad = LayoutPad(number="2", x=1.0, y=2.0, net="3V3") + via = LayoutVia(x=1.0, y=2.0, net="3V3", drill=0.2) + assert type(via) is not type(pad) + assert "number" not in type(via).model_fields + assert "number" in type(pad).model_fields