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.
This commit is contained in:
2026-09-20 16:41:50 +02:00
parent 34a9b30a35
commit 31fd389b38
33 changed files with 2220 additions and 34 deletions
@@ -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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 KiB

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 756 B

After

Width:  |  Height:  |  Size: 606 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

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>
+447
View File
@@ -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,
)
+528
View File
@@ -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] = []
+316
View File
@@ -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 lobiettivo 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
---
Binary file not shown.

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 606 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

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)}
/>
);
}
+36
View File
@@ -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)