"""Pydantic models for PeriscopeX: datasheet constraints and design graph.""" from __future__ import annotations from enum import Enum from typing import Annotated, Any, Literal from pydantic import BaseModel, Discriminator, Field, Tag, field_validator, model_validator class Pin(BaseModel): number: int | str name: str description: str | None = None functions: list[str] | None = None class PackageInfo(BaseModel): base_family: str package: str pin_count: int description: str | None = None class AbsMaxRating(BaseModel): parameter: str min: float | None = None max: float | None = None unit: str source_page: int class Rule(BaseModel): rule_id: str | None = None # {MPN}-{001} description: str source_page: int def _check_subtype(v: object) -> str | None: """Shared pre-validator for component_subtype fields.""" if v is None or v == "": return None from backend.periscopex.taxonomy import validate_subtype return validate_subtype(str(v)) class InternalFeatures(BaseModel): """Block-diagram extras: ESD clamps, on-die pull-ups, analog switches.""" esd_clamp_pins: list[str] = [] pullup_pins: list[str] = [] analog_switch: list[str] = [] class ComponentConstraints(BaseModel): mpn: str model_version: str = "1.0.0" # semver; bumped on prune (patch) or skill update (minor) component_subtype: str | None = None # dotted taxonomy path, e.g. "ic.ldo", "ic.mcu" package_info: PackageInfo | None = None pintable: list[Pin] absolute_maximum_ratings: list[AbsMaxRating] rules: list[Rule] internal_features: InternalFeatures | None = None layout_rules: list[dict] = [] _validate_subtype = field_validator("component_subtype", mode="before")( staticmethod(_check_subtype) ) def pin_by_number(self, number: int | str) -> Pin | None: """Look up a pin by its number.""" for p in self.pintable: if str(p.number) == str(number): return p return None # --------------------------------------------------------------------------- # Design graph models # --------------------------------------------------------------------------- class NetType(str, Enum): POWER = "power" GROUND = "ground" SIGNAL = "signal" UNKNOWN = "unknown" class ComponentType(str, Enum): RESISTOR = "resistor" CAPACITOR = "capacitor" INDUCTOR = "inductor" IC = "ic" CONNECTOR = "connector" CRYSTAL = "crystal" DISCRETE = "discrete" TRANSFORMER = "transformer" FUSE = "fuse" SWITCH = "switch" TEST_POINT = "test_point" FIDUCIAL = "fiducial" MECHANICAL = "mechanical" UNKNOWN = "unknown" # --------------------------------------------------------------------------- # Component specs taxonomy — type-specific, standardised-unit models # --------------------------------------------------------------------------- class ResistorSpecs(BaseModel): """Standardised resistor parameters. Value always in ohms.""" specs_type: Literal["resistor"] = "resistor" component_subtype: str | None = None # e.g. "passive.resistor" value_ohms: float value_formatted: str tolerance: str | None = None # "±1%" or "±0.5ohm" package: str | None = None power_rating_w: str | None = None _validate_subtype = field_validator("component_subtype", mode="before")( staticmethod(_check_subtype) ) class CapacitorSpecs(BaseModel): """Standardised capacitor parameters. Value always in farads.""" specs_type: Literal["capacitor"] = "capacitor" component_subtype: str | None = None # e.g. "passive.capacitor.ceramic" value_farads: float value_formatted: str tolerance: str | None = None # "±10%" or "±0.25pF" package: str | None = None voltage_rating_v: str | None = None dielectric: str | None = None _validate_subtype = field_validator("component_subtype", mode="before")( staticmethod(_check_subtype) ) class InductorSpecs(BaseModel): """Standardised inductor / ferrite-bead parameters.""" specs_type: Literal["inductor"] = "inductor" component_subtype: str | None = None # e.g. "passive.inductor" or "passive.ferrite_bead" value_henries: float | None = None value_formatted: str tolerance: str | None = None # "±5%" or "±0.1uH" package: str | None = None current_rating_a: str | None = None dcr_ohms: float | None = None impedance_ohm: float | None = None # ferrite beads: Z at test frequency _validate_subtype = field_validator("component_subtype", mode="before")( staticmethod(_check_subtype) ) @model_validator(mode="after") def _require_primary_value(self) -> InductorSpecs: if self.component_subtype == "passive.ferrite_bead": if self.impedance_ohm is None: raise ValueError("ferrite bead requires impedance_ohm") return self if self.value_henries is None: raise ValueError("inductor requires value_henries") return self class SimpleComponentSpecs(BaseModel): """Specs for discrete/simple components. Schema defined in taxonomy JSON.""" specs_type: str # taxonomy type: "discrete", "connector", "crystal", etc. component_subtype: str | None = None values: dict[str, float | str | None] = {} pintable: list[Pin] = [] package_info: PackageInfo | None = None _validate_subtype = field_validator("component_subtype", mode="before")( staticmethod(_check_subtype) ) def pin_by_number(self, number: int | str) -> Pin | None: """Look up a pin by its number.""" for p in self.pintable: if str(p.number) == str(number): return p return None def _specs_tag(v: Any) -> str: """Route to the correct specs model based on specs_type.""" st = v.get("specs_type") if isinstance(v, dict) else v.specs_type return st if st in ("resistor", "capacitor", "inductor") else "simple" ComponentSpecs = Annotated[ Annotated[ResistorSpecs, Tag("resistor")] | Annotated[CapacitorSpecs, Tag("capacitor")] | Annotated[InductorSpecs, Tag("inductor")] | Annotated[SimpleComponentSpecs, Tag("simple")], Discriminator(_specs_tag), ] class ComponentModel(BaseModel): """Persisted specs file — one per MPN in component-models/.""" mpn: str specs: ComponentSpecs # --------------------------------------------------------------------------- # Design graph models # --------------------------------------------------------------------------- class PinConnection(BaseModel): """A pin on a component that participates in a net.""" component_ref: str pin_number: str pin_name: str | None = None # enriched from datasheet pintable class Net(BaseModel): """An electrical net with mutable type/voltage for agent refinement.""" name: str net_type: NetType = NetType.UNKNOWN voltage: float | None = None pins: list[PinConnection] = [] class Component(BaseModel): """A placed component in the design graph (topology only).""" reference: str value: str footprint: str component_type: ComponentType = ComponentType.UNKNOWN component_subtype: str | None = None # dotted taxonomy path, e.g. "ic.ldo", "ic.mcu" mpn: str | None = None pins: dict[str, str] = {} # pin_number -> net_name specs: ComponentSpecs | None = None _validate_subtype = field_validator("component_subtype", mode="before")( staticmethod(_check_subtype) ) class CadIndexEntry(BaseModel): """KiCad symbol identity for plugin pan-and-zoom.""" uuid: str = "" sheet: str = "" class DesignGraph(BaseModel): """ Bipartite design graph: Components <-> Nets. Traversal paths: component.pins[pin_num] -> net_name -> graph.nets[net_name].pins -> other components net.pins[i].component_ref -> graph.components[ref] -> its other pins/nets """ components: dict[str, Component] = {} nets: dict[str, Net] = {} # KiCad property table vs uploaded BOM (empty on PADS/EDIF). bom_fields: dict[str, dict] = {} schematic_fields: dict[str, dict] = {} cad_index: dict[str, CadIndexEntry] = {} # -- Traversal helpers -------------------------------------------------- def components_on_net(self, net_name: str) -> list[str]: """All component refs connected to a net.""" net = self.nets.get(net_name) if not net: return [] return list({pc.component_ref for pc in net.pins}) def nets_of_component(self, ref: str) -> list[str]: """All net names a component touches.""" comp = self.components.get(ref) if not comp: return [] return list(set(comp.pins.values())) def neighbors(self, ref: str) -> dict[str, list[str]]: """Components sharing a net with *ref*, grouped by net name.""" result: dict[str, list[str]] = {} for net_name in self.nets_of_component(ref): others = [r for r in self.components_on_net(net_name) if r != ref] if others: result[net_name] = others return result def components_by_type(self, comp_type: ComponentType) -> list[str]: """All refs matching a component type.""" return [r for r, c in self.components.items() if c.component_type == comp_type] def power_nets(self) -> list[Net]: """All power and ground nets.""" return [n for n in self.nets.values() if n.net_type in (NetType.POWER, NetType.GROUND)] def capacitors_on_net(self, net_name: str) -> list[str]: """Capacitor refs connected to a net (useful for decoupling checks).""" return [ r for r in self.components_on_net(net_name) if (c := self.components.get(r)) is not None and c.component_type == ComponentType.CAPACITOR ] def components_by_subtype(self, prefix: str) -> list[str]: """All refs whose component_subtype starts with *prefix*. Examples: components_by_subtype("ic.power") -> all power ICs components_by_subtype("passive.capacitor") -> all capacitors components_by_subtype("passive") -> all passives """ prefix_dot = prefix if prefix.endswith(".") else prefix + "." return [ r for r, c in self.components.items() if c.component_subtype and ( c.component_subtype == prefix or c.component_subtype.startswith(prefix_dot) ) ] def pin_net(self, ref: str, pin_number: str) -> str | None: """Net name for a specific pin on a component.""" comp = self.components.get(ref) if not comp: return None return comp.pins.get(pin_number) # --------------------------------------------------------------------------- # Validation report models # --------------------------------------------------------------------------- class Finding(BaseModel): """A single review finding — an issue found during direct datasheet review.""" finding_id: str | None = None designator: str mpn: str = "" aspect: str | None = None # "power_supply", "clock", etc. (for complex ICs) finding: str # What was observed in the actual circuit why: str = "" # Why it matters — from the datasheet source_page: int | None = None # Datasheet page (null for deterministic checks) source_quote: str = "" # Verbatim datasheet text supporting the finding (for PDF highlight) source_designator: str | None = None # Designator whose datasheet source_page/source_quote refer to; None = this finding's own `designator`. Set when the evidence came from a connected component's datasheet excerpt (get_datasheet_excerpt), so the viewer opens the right PDF at the right page. status: Literal["ERROR", "WARNING", "INFO"] recommendation: str = "" reference: str = "" source: str | None = None # None/"review" = LLM; "pin_mux_check"/"led_current_check"/"supply_decoupling_check"/… = deterministic net: str | None = None # net name for CAD telemetry / SI filters pins: list[str] = [] # e.g. ["U3.54"] for pan-and-zoom rule_id: str | None = None # deterministic id, e.g. PE-MUX-001 cad_sheet: str | None = None # schematic sheet filename for plugin sync cad_uuid: str | None = None # KiCad symbol/pin uuid variant: str | None = None # DNP / ECO / assembly variant # 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 = "" 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] = []