Native overlay of graph, parsers, models, taxonomy; Periscope mark.
Src-first imports now load graph/parsers/EDIF/models/taxonomy from periscope/src. Inherited copies stay in dependency/. Taxonomy JSON path uses repo_paths. New periscope/lens mark at existing 16/32/180/192/512 favicon sizes; header uses PeriscopeMark instead of the lucide CPU glyph.
@@ -2,6 +2,14 @@
|
||||
|
||||
What's new in Periscope.
|
||||
|
||||
## 2.42.0 — 2026-09-20 — Native graph/parsers/models/taxonomy overlay + Periscope mark
|
||||
|
||||
Graph builder, PADS/EDIF/BOM parsers, Pydantic models, and taxonomy loader resolve from `periscope/src`. Inherited copies stay in `periscope/dependency/` (not deleted). KiCad schematic parser was already native. Taxonomy JSON still lives in the inherited `taxonomy/` tree. New Periscope mark (periscope/lens, not the PinScope CPU glyph) at the existing favicon and PWA pixel sizes.
|
||||
|
||||
- [New] `periscope/src/backend/periscopex/{graph,models,parsers,parsers_edif,taxonomy}.py` overlay Docker/src-first imports.
|
||||
- [Changed] `taxonomy.TAXONOMY_DIR` uses `repo_paths` so src overlay still reads `dependency/taxonomy`.
|
||||
- [New] Brand mark: `public/brand/periscope-mark.svg`, favicon_io 16/32/180/192/512, header `PeriscopeMark`.
|
||||
|
||||
## 2.41.0 — 2026-09-20 — Native job workspace for PCB and placement
|
||||
|
||||
`pcb_pipeline` and `placement_pipeline` no longer import PinScope `pipeline.py`. Workspace download/upload and the in-memory event broker live in `periscope/src` (`job_workspace.py`). Analysis `run_pipeline` still lives in `dependency/` and re-exports the same singleton so `pipeline_worker.set_broker` keeps PCB SSE on the GCS event log. Parsers/graph unchanged. `pipeline.py` not deleted.
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" role="img" aria-label="Periscope">
|
||||
<rect width="32" height="32" rx="8" fill="#0B1220"/>
|
||||
<rect x="13.2" y="11" width="5.6" height="14.2" rx="1.4" fill="#3B82F6"/>
|
||||
<rect x="13.2" y="6.4" width="11.4" height="5.4" rx="1.6" fill="#3B82F6"/>
|
||||
<circle cx="22.6" cy="9.1" r="4.05" fill="#0B1220"/>
|
||||
<circle cx="22.6" cy="9.1" r="3.15" fill="none" stroke="#67E8F9" stroke-width="1.15"/>
|
||||
<circle cx="22.6" cy="9.1" r="1.15" fill="#67E8F9"/>
|
||||
<circle cx="21.35" cy="8.15" r="0.42" fill="#E0F2FE"/>
|
||||
<circle cx="23.7" cy="8.45" r="0.38" fill="#E0F2FE"/>
|
||||
<circle cx="22.15" cy="10.2" r="0.32" fill="#E0F2FE"/>
|
||||
<circle cx="23.55" cy="10.05" r="0.32" fill="#E0F2FE"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 737 B |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 131 KiB After Width: | Height: | Size: 123 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 756 B After Width: | Height: | Size: 606 B |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 2.1 KiB |
@@ -14,6 +14,6 @@
|
||||
}
|
||||
],
|
||||
"theme_color": "#3B82F6",
|
||||
"background_color": "#ffffff",
|
||||
"background_color": "#0B1220",
|
||||
"display": "standalone"
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Cpu, ArrowRight } from "lucide-react";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { PeriscopeMark } from "@/components/brand/periscope-mark";
|
||||
import { useOptionalAuth } from "@/hooks/use-optional-auth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ThemeToggle } from "@/components/theme/theme-toggle";
|
||||
@@ -13,7 +14,7 @@ export function Nav() {
|
||||
<header className="sticky top-0 z-50 border-b border-border/50 bg-background/80 backdrop-blur-lg">
|
||||
<div className="mx-auto flex h-14 max-w-6xl items-center justify-between px-6">
|
||||
<Link href="/" className="flex items-center gap-2">
|
||||
<Cpu className="h-5 w-5 text-blue-500" />
|
||||
<PeriscopeMark className="h-5 w-5" size={20} />
|
||||
<span className="text-sm font-semibold tracking-tight">Periscope</span>
|
||||
</Link>
|
||||
<nav className="hidden sm:flex items-center gap-6 text-sm text-muted-foreground">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Link from "next/link";
|
||||
import { Cpu, ArrowRight } from "lucide-react";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { PeriscopeMark } from "@/components/brand/periscope-mark";
|
||||
import { ContactForm } from "./contact-form";
|
||||
import { Nav } from "./nav";
|
||||
import { CONTACT_EMAIL, OPERATOR_NAME, pageMetadata } from "@/lib/site";
|
||||
@@ -53,7 +54,7 @@ export default function ContactPage() {
|
||||
<footer className="border-t border-border/50">
|
||||
<div className="mx-auto max-w-6xl px-6 py-8 flex items-center justify-between text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<Cpu className="h-4 w-4 text-blue-500" />
|
||||
<PeriscopeMark className="h-4 w-4" size={16} />
|
||||
<span>Periscope</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { Metadata } from "next";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { Cpu, Shield, Lock, ServerCog, Users, GitBranch } from "lucide-react";
|
||||
import { Shield, Lock, ServerCog, Users, GitBranch } from "lucide-react";
|
||||
import { PeriscopeMark } from "@/components/brand/periscope-mark";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ThemeToggle } from "@/components/theme/theme-toggle";
|
||||
import { APP_VERSION_DATE } from "@/lib/version";
|
||||
@@ -103,7 +104,7 @@ export default function LandingPage() {
|
||||
<header className="sticky top-0 z-50 border-b border-border/50 bg-background/80 backdrop-blur-lg">
|
||||
<div className="mx-auto flex h-14 max-w-6xl items-center justify-between px-6">
|
||||
<Link href="/" className="flex items-center gap-2">
|
||||
<Cpu className="h-5 w-5 text-blue-500" />
|
||||
<PeriscopeMark className="h-5 w-5" size={20} />
|
||||
<span className="text-sm font-semibold tracking-tight">
|
||||
Periscope
|
||||
</span>
|
||||
@@ -393,7 +394,7 @@ export default function LandingPage() {
|
||||
<div className="flex flex-col gap-8 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center gap-2 text-sm text-foreground">
|
||||
<Cpu className="h-4 w-4 text-blue-500" />
|
||||
<PeriscopeMark className="h-4 w-4" size={16} />
|
||||
<span>{SITE_NAME}</span>
|
||||
</div>
|
||||
<p className="text-xs leading-relaxed text-muted-foreground max-w-sm">
|
||||
|
||||
@@ -26,23 +26,15 @@ export default function Image() {
|
||||
<svg
|
||||
width="40"
|
||||
height="40"
|
||||
viewBox="0 0 24 24"
|
||||
viewBox="0 0 32 32"
|
||||
fill="none"
|
||||
stroke="#3b82f6"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<rect x="4" y="4" width="16" height="16" rx="2" />
|
||||
<rect x="9" y="9" width="6" height="6" />
|
||||
<path d="M9 2v2" />
|
||||
<path d="M15 2v2" />
|
||||
<path d="M9 20v2" />
|
||||
<path d="M15 20v2" />
|
||||
<path d="M20 9h2" />
|
||||
<path d="M20 15h2" />
|
||||
<path d="M2 9h2" />
|
||||
<path d="M2 15h2" />
|
||||
<rect width="32" height="32" rx="8" fill="#0B1220" />
|
||||
<rect x="13.2" y="11" width="5.6" height="14.2" rx="1.4" fill="#3B82F6" />
|
||||
<rect x="13.2" y="6.4" width="11.4" height="5.4" rx="1.6" fill="#3B82F6" />
|
||||
<circle cx="22.6" cy="9.1" r="4.05" fill="#0B1220" />
|
||||
<circle cx="22.6" cy="9.1" r="3.15" stroke="#67E8F9" strokeWidth="1.15" />
|
||||
<circle cx="22.6" cy="9.1" r="1.15" fill="#67E8F9" />
|
||||
</svg>
|
||||
<div
|
||||
style={{
|
||||
|
||||
@@ -5,7 +5,6 @@ import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { Suspense, useState, useEffect, type ReactNode } from "react";
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Cpu,
|
||||
Shield,
|
||||
ArrowLeft,
|
||||
ClipboardList,
|
||||
@@ -20,6 +19,7 @@ import {
|
||||
Boxes,
|
||||
CircuitBoard,
|
||||
} from "lucide-react";
|
||||
import { PeriscopeMark } from "@/components/brand/periscope-mark";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAuthApi } from "@/hooks/use-auth-api";
|
||||
import { useOptionalUser } from "@/hooks/use-optional-auth";
|
||||
@@ -72,7 +72,7 @@ export function Sidebar() {
|
||||
<aside className="w-56 shrink-0 border-r border-border bg-card flex flex-col min-h-0">
|
||||
<div className="px-4 py-4 border-b border-border">
|
||||
<Link href="/dashboard" className="flex items-center gap-2">
|
||||
<Cpu className="h-5 w-5 text-blue-500" />
|
||||
<PeriscopeMark className="h-5 w-5" size={20} />
|
||||
<span className="text-sm font-semibold tracking-tight">Periscope</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -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
|
||||
</Link>
|
||||
<Link href="/" className="flex items-center gap-2">
|
||||
<Cpu className="h-4 w-4 text-blue-500" />
|
||||
<PeriscopeMark className="h-4 w-4" size={16} />
|
||||
<span className="text-sm font-medium">Periscope</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -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
|
||||
</Link>
|
||||
<Link href="/" className="flex items-center gap-2">
|
||||
<Cpu className="h-4 w-4 text-blue-500" />
|
||||
<PeriscopeMark className="h-4 w-4" size={16} />
|
||||
<span className="text-sm font-medium">Periscope</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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] = []
|
||||
|
||||
@@ -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 ``<export`` / ``<?xml``;
|
||||
KiCad s-expr netlist with ``(export``; schematic with ``(kicad_sch``.
|
||||
PADS-PCB ASCII (``*PADS-PCB*``) is the default when no marker is found.
|
||||
"""
|
||||
if isinstance(content, bytes):
|
||||
text = content[:2048].decode("utf-8", errors="replace")
|
||||
else:
|
||||
text = content[:2048]
|
||||
head = text.lstrip("\ufeff").lstrip()
|
||||
low = head[:40].lower()
|
||||
if low.startswith("(edif"):
|
||||
return "edif"
|
||||
if low.startswith("(kicad_sch"):
|
||||
return "kicad_sch"
|
||||
if low.startswith("(export"):
|
||||
return "kicad_sexp"
|
||||
if low.startswith("<?xml") or low.startswith("<export"):
|
||||
return "kicad_xml"
|
||||
return "pads"
|
||||
|
||||
|
||||
def parse_netlist_any(
|
||||
path: str | Path,
|
||||
known_refs: set[str] | None = None,
|
||||
*,
|
||||
include_subdesigns: set[str] | None = None,
|
||||
) -> 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
|
||||
@@ -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 ``(<head> <id> ...)``.
|
||||
|
||||
Handles ``(<head> (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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
---
|
||||
|
||||
|
After Width: | Height: | Size: 100 KiB |
@@ -0,0 +1,12 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" role="img" aria-label="Periscope">
|
||||
<rect width="32" height="32" rx="8" fill="#0B1220"/>
|
||||
<rect x="13.2" y="11" width="5.6" height="14.2" rx="1.4" fill="#3B82F6"/>
|
||||
<rect x="13.2" y="6.4" width="11.4" height="5.4" rx="1.6" fill="#3B82F6"/>
|
||||
<circle cx="22.6" cy="9.1" r="4.05" fill="#0B1220"/>
|
||||
<circle cx="22.6" cy="9.1" r="3.15" fill="none" stroke="#67E8F9" stroke-width="1.15"/>
|
||||
<circle cx="22.6" cy="9.1" r="1.15" fill="#67E8F9"/>
|
||||
<circle cx="21.35" cy="8.15" r="0.42" fill="#E0F2FE"/>
|
||||
<circle cx="23.7" cy="8.45" r="0.38" fill="#E0F2FE"/>
|
||||
<circle cx="22.15" cy="10.2" r="0.32" fill="#E0F2FE"/>
|
||||
<circle cx="23.55" cy="10.05" r="0.32" fill="#E0F2FE"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 737 B |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 606 B |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
<img
|
||||
src="/brand/periscope-mark.svg"
|
||||
alt=""
|
||||
width={size}
|
||||
height={size}
|
||||
className={cn("shrink-0", className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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)
|
||||