Rewrite Periscope models.py with disjoint layout primitives.
Pad, via, track, and zone stay separate types. Inherited models.py remains in dependency/. Public graph/finding fields are unchanged.
This commit is contained in:
@@ -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] = []
|
||||||
|
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user