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.
|
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
|
## 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.
|
`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",
|
"theme_color": "#3B82F6",
|
||||||
"background_color": "#ffffff",
|
"background_color": "#0B1220",
|
||||||
"display": "standalone"
|
"display": "standalone"
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
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 { useOptionalAuth } from "@/hooks/use-optional-auth";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ThemeToggle } from "@/components/theme/theme-toggle";
|
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">
|
<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">
|
<div className="mx-auto flex h-14 max-w-6xl items-center justify-between px-6">
|
||||||
<Link href="/" className="flex items-center gap-2">
|
<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>
|
<span className="text-sm font-semibold tracking-tight">Periscope</span>
|
||||||
</Link>
|
</Link>
|
||||||
<nav className="hidden sm:flex items-center gap-6 text-sm text-muted-foreground">
|
<nav className="hidden sm:flex items-center gap-6 text-sm text-muted-foreground">
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import Link from "next/link";
|
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 { ContactForm } from "./contact-form";
|
||||||
import { Nav } from "./nav";
|
import { Nav } from "./nav";
|
||||||
import { CONTACT_EMAIL, OPERATOR_NAME, pageMetadata } from "@/lib/site";
|
import { CONTACT_EMAIL, OPERATOR_NAME, pageMetadata } from "@/lib/site";
|
||||||
@@ -53,7 +54,7 @@ export default function ContactPage() {
|
|||||||
<footer className="border-t border-border/50">
|
<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="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">
|
<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>
|
<span>Periscope</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { ThemeToggle } from "@/components/theme/theme-toggle";
|
import { ThemeToggle } from "@/components/theme/theme-toggle";
|
||||||
import { APP_VERSION_DATE } from "@/lib/version";
|
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">
|
<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">
|
<div className="mx-auto flex h-14 max-w-6xl items-center justify-between px-6">
|
||||||
<Link href="/" className="flex items-center gap-2">
|
<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">
|
<span className="text-sm font-semibold tracking-tight">
|
||||||
Periscope
|
Periscope
|
||||||
</span>
|
</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-8 sm:flex-row sm:items-start sm:justify-between">
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="flex items-center gap-2 text-sm text-foreground">
|
<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>
|
<span>{SITE_NAME}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs leading-relaxed text-muted-foreground max-w-sm">
|
<p className="text-xs leading-relaxed text-muted-foreground max-w-sm">
|
||||||
|
|||||||
@@ -26,23 +26,15 @@ export default function Image() {
|
|||||||
<svg
|
<svg
|
||||||
width="40"
|
width="40"
|
||||||
height="40"
|
height="40"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 32 32"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="#3b82f6"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
>
|
>
|
||||||
<rect x="4" y="4" width="16" height="16" rx="2" />
|
<rect width="32" height="32" rx="8" fill="#0B1220" />
|
||||||
<rect x="9" y="9" width="6" height="6" />
|
<rect x="13.2" y="11" width="5.6" height="14.2" rx="1.4" fill="#3B82F6" />
|
||||||
<path d="M9 2v2" />
|
<rect x="13.2" y="6.4" width="11.4" height="5.4" rx="1.6" fill="#3B82F6" />
|
||||||
<path d="M15 2v2" />
|
<circle cx="22.6" cy="9.1" r="4.05" fill="#0B1220" />
|
||||||
<path d="M9 20v2" />
|
<circle cx="22.6" cy="9.1" r="3.15" stroke="#67E8F9" strokeWidth="1.15" />
|
||||||
<path d="M15 20v2" />
|
<circle cx="22.6" cy="9.1" r="1.15" fill="#67E8F9" />
|
||||||
<path d="M20 9h2" />
|
|
||||||
<path d="M20 15h2" />
|
|
||||||
<path d="M2 9h2" />
|
|
||||||
<path d="M2 15h2" />
|
|
||||||
</svg>
|
</svg>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { usePathname, useSearchParams } from "next/navigation";
|
|||||||
import { Suspense, useState, useEffect, type ReactNode } from "react";
|
import { Suspense, useState, useEffect, type ReactNode } from "react";
|
||||||
import {
|
import {
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
Cpu,
|
|
||||||
Shield,
|
Shield,
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
@@ -20,6 +19,7 @@ import {
|
|||||||
Boxes,
|
Boxes,
|
||||||
CircuitBoard,
|
CircuitBoard,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { PeriscopeMark } from "@/components/brand/periscope-mark";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useAuthApi } from "@/hooks/use-auth-api";
|
import { useAuthApi } from "@/hooks/use-auth-api";
|
||||||
import { useOptionalUser } from "@/hooks/use-optional-auth";
|
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">
|
<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">
|
<div className="px-4 py-4 border-b border-border">
|
||||||
<Link href="/dashboard" className="flex items-center gap-2">
|
<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>
|
<span className="text-sm font-semibold tracking-tight">Periscope</span>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</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 Link from "next/link";
|
||||||
import { OPERATOR_NAME } from "@/lib/site";
|
import { OPERATOR_NAME } from "@/lib/site";
|
||||||
|
|
||||||
@@ -96,7 +97,7 @@ export function ChangelogTimeline({ content }: { content: string }) {
|
|||||||
Back to home
|
Back to home
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/" className="flex items-center gap-2">
|
<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>
|
<span className="text-sm font-medium">Periscope</span>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</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 Link from "next/link";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import Markdown from "react-markdown";
|
import Markdown from "react-markdown";
|
||||||
@@ -115,7 +116,7 @@ export function LegalPageShell({ children }: { children: ReactNode }) {
|
|||||||
Back to home
|
Back to home
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/" className="flex items-center gap-2">
|
<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>
|
<span className="text-sm font-medium">Periscope</span>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</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
|
# 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.
|
**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.
|
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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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` |
|
| 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) |
|
| Deploy | `scripts/update-periscope.sh`, `docker-compose.yml`, `periscope/src/backend/Dockerfile` | NEW (nomi `pinscope_*` ancora WEAK) |
|
||||||
| Plugin KiCad | `periscope/src/plugins/kicad/` | NEW |
|
| 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 |
|
| 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)
|
9. **Loop `validate.py` / `validation_tools`** — reviewer nativo (C)
|
||||||
10. **PCB/schema smettono di chiamare `_parse_review` PinScope** (C4)
|
10. **PCB/schema smettono di chiamare `_parse_review` PinScope** (C4)
|
||||||
11. **UI FindingCard/wizard** (D)
|
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
|
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)
|
||||||