Add auto-schematic generation from SPICE netlists
Parse netlists into component graphs and render circuit diagrams via SchemDraw. Two layout strategies: loop layout for simple 2-terminal circuits (RC, RL, voltage divider) and labeled grid for complex circuits with active devices (BJT amplifiers, MOSFET). Backend: netlist parser, schematic engine, POST API endpoint. Frontend: SchematicViewer with zoom/download, stacked cell layout showing schematic + SPICE editor + waveform simultaneously.
This commit is contained in:
parent
42f4428295
commit
de7a29c69e
@ -11,6 +11,7 @@ dependencies = [
|
||||
"pydantic>=2.0",
|
||||
"numpy>=1.24.0",
|
||||
"websockets>=12.0",
|
||||
"schemdraw>=0.19",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
636
backend/src/spicebook/engine/schematic.py
Normal file
636
backend/src/spicebook/engine/schematic.py
Normal file
@ -0,0 +1,636 @@
|
||||
"""Auto-generate circuit schematics from SPICE netlists using SchemDraw.
|
||||
|
||||
Pipeline: netlist text → parse → component list → node graph → layout → SVG
|
||||
"""
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── Data Structures ─────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class SpiceComponent:
|
||||
"""A parsed SPICE netlist component."""
|
||||
|
||||
name: str
|
||||
prefix: str
|
||||
nodes: list[str]
|
||||
value: str = ""
|
||||
model: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParsedNetlist:
|
||||
"""Parsed representation of a SPICE netlist."""
|
||||
|
||||
title: str = ""
|
||||
components: list[SpiceComponent] = field(default_factory=list)
|
||||
models: dict[str, str] = field(default_factory=dict)
|
||||
|
||||
|
||||
# ── Ground / Supply Detection ───────────────────────────────────
|
||||
|
||||
GROUND_NAMES = {"0", "gnd"}
|
||||
SUPPLY_NAMES = {"vcc", "vdd", "vee", "vss"}
|
||||
|
||||
|
||||
def _is_ground(node: str) -> bool:
|
||||
return node.lower() in GROUND_NAMES
|
||||
|
||||
|
||||
def _is_supply(node: str) -> bool:
|
||||
return node.lower() in SUPPLY_NAMES
|
||||
|
||||
|
||||
# ── SPICE Netlist Parser ───────────────────────────────────────
|
||||
|
||||
|
||||
def parse_netlist(text: str) -> ParsedNetlist:
|
||||
"""Parse SPICE netlist text into structured components."""
|
||||
lines = text.split("\n")
|
||||
if not lines:
|
||||
return ParsedNetlist()
|
||||
|
||||
# First non-blank, non-directive line is the title (SPICE convention)
|
||||
title = ""
|
||||
first_content = 0
|
||||
for i, line in enumerate(lines):
|
||||
stripped = line.strip()
|
||||
if stripped and not stripped.startswith(("*", ";", ".")):
|
||||
title = stripped
|
||||
first_content = i + 1
|
||||
break
|
||||
|
||||
# Pre-scan for .model statements to determine BJT/MOSFET polarity
|
||||
models: dict[str, str] = {}
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
if stripped.lower().startswith(".model"):
|
||||
parts = stripped.split()
|
||||
if len(parts) >= 3:
|
||||
model_name = parts[1]
|
||||
model_type = parts[2].split("(")[0].strip().upper()
|
||||
models[model_name] = model_type
|
||||
|
||||
# Merge continuation lines (SPICE + prefix)
|
||||
merged: list[str] = []
|
||||
for line in lines[first_content:]:
|
||||
stripped = line.strip()
|
||||
if not stripped:
|
||||
continue
|
||||
if stripped.startswith("+") and merged:
|
||||
merged[-1] += " " + stripped[1:].strip()
|
||||
else:
|
||||
merged.append(stripped)
|
||||
|
||||
# Parse each line into components
|
||||
components: list[SpiceComponent] = []
|
||||
for line in merged:
|
||||
if line.startswith(("*", ";", "$")):
|
||||
continue
|
||||
if line.startswith("."):
|
||||
continue
|
||||
comp = _parse_component_line(line, models)
|
||||
if comp is not None:
|
||||
components.append(comp)
|
||||
|
||||
return ParsedNetlist(title=title, components=components, models=models)
|
||||
|
||||
|
||||
def _parse_component_line(line: str, models: dict[str, str]) -> SpiceComponent | None:
|
||||
"""Parse a single SPICE component line."""
|
||||
parts = line.split()
|
||||
if not parts:
|
||||
return None
|
||||
|
||||
name = parts[0]
|
||||
prefix = name[0].upper()
|
||||
|
||||
parsers = {
|
||||
"R": _parse_rcl,
|
||||
"C": _parse_rcl,
|
||||
"L": _parse_rcl,
|
||||
"V": _parse_source,
|
||||
"I": _parse_source,
|
||||
"D": _parse_diode,
|
||||
"Q": _parse_bjt,
|
||||
"M": _parse_mosfet,
|
||||
"X": _parse_subcircuit,
|
||||
"E": _parse_four_node,
|
||||
"G": _parse_four_node,
|
||||
"S": _parse_four_node,
|
||||
"F": _parse_ccxx,
|
||||
"H": _parse_ccxx,
|
||||
"B": _parse_behavioral,
|
||||
}
|
||||
|
||||
parser = parsers.get(prefix)
|
||||
if parser is None:
|
||||
return None
|
||||
|
||||
if prefix in ("Q", "M"):
|
||||
return parser(parts, models)
|
||||
return parser(parts)
|
||||
|
||||
|
||||
def _parse_rcl(parts: list[str]) -> SpiceComponent | None:
|
||||
"""Parse R, C, L: NAME N+ N- VALUE."""
|
||||
if len(parts) < 4:
|
||||
return None
|
||||
return SpiceComponent(
|
||||
name=parts[0],
|
||||
prefix=parts[0][0].upper(),
|
||||
nodes=[parts[1], parts[2]],
|
||||
value=parts[3],
|
||||
)
|
||||
|
||||
|
||||
def _parse_source(parts: list[str]) -> SpiceComponent | None:
|
||||
"""Parse V, I: NAME N+ N- [DC val] [AC val] [transient spec]."""
|
||||
if len(parts) < 3:
|
||||
return None
|
||||
return SpiceComponent(
|
||||
name=parts[0],
|
||||
prefix=parts[0][0].upper(),
|
||||
nodes=[parts[1], parts[2]],
|
||||
value=" ".join(parts[3:]) if len(parts) > 3 else "",
|
||||
)
|
||||
|
||||
|
||||
def _parse_diode(parts: list[str]) -> SpiceComponent | None:
|
||||
"""Parse D: NAME N+ N- MODEL."""
|
||||
if len(parts) < 4:
|
||||
return None
|
||||
return SpiceComponent(
|
||||
name=parts[0], prefix="D",
|
||||
nodes=[parts[1], parts[2]],
|
||||
model=parts[3],
|
||||
)
|
||||
|
||||
|
||||
def _parse_bjt(parts: list[str], models: dict[str, str]) -> SpiceComponent | None:
|
||||
"""Parse Q: NAME NC NB NE [NS] MODEL."""
|
||||
if len(parts) < 5:
|
||||
return None
|
||||
name = parts[0]
|
||||
# Check if parts[4] is a known model → 3-node form
|
||||
if parts[4] in models:
|
||||
return SpiceComponent(
|
||||
name=name, prefix="Q",
|
||||
nodes=[parts[1], parts[2], parts[3]],
|
||||
model=parts[4],
|
||||
)
|
||||
# 4-node form (with substrate)
|
||||
if len(parts) >= 6:
|
||||
return SpiceComponent(
|
||||
name=name, prefix="Q",
|
||||
nodes=[parts[1], parts[2], parts[3], parts[4]],
|
||||
model=parts[5],
|
||||
)
|
||||
# Default: 3-node
|
||||
return SpiceComponent(
|
||||
name=name, prefix="Q",
|
||||
nodes=[parts[1], parts[2], parts[3]],
|
||||
model=parts[4],
|
||||
)
|
||||
|
||||
|
||||
def _parse_mosfet(parts: list[str], models: dict[str, str]) -> SpiceComponent | None:
|
||||
"""Parse M: NAME ND NG NS [NB] MODEL."""
|
||||
if len(parts) < 5:
|
||||
return None
|
||||
name = parts[0]
|
||||
# 4-node + model (standard)
|
||||
if len(parts) >= 6 and not any(c in parts[5] for c in "="):
|
||||
return SpiceComponent(
|
||||
name=name, prefix="M",
|
||||
nodes=[parts[1], parts[2], parts[3], parts[4]],
|
||||
model=parts[5],
|
||||
)
|
||||
# 3-node + model
|
||||
return SpiceComponent(
|
||||
name=name, prefix="M",
|
||||
nodes=[parts[1], parts[2], parts[3]],
|
||||
model=parts[4],
|
||||
)
|
||||
|
||||
|
||||
def _parse_subcircuit(parts: list[str]) -> SpiceComponent | None:
|
||||
"""Parse X: NAME N1 N2 ... SUBCKT_NAME [params]."""
|
||||
if len(parts) < 3:
|
||||
return None
|
||||
# Find where key=value params start
|
||||
param_start = len(parts)
|
||||
for i in range(len(parts) - 1, 0, -1):
|
||||
if "=" in parts[i]:
|
||||
param_start = i
|
||||
else:
|
||||
break
|
||||
tokens = parts[1:param_start]
|
||||
if not tokens:
|
||||
return None
|
||||
return SpiceComponent(
|
||||
name=parts[0], prefix="X",
|
||||
nodes=tokens[:-1] if len(tokens) > 1 else [],
|
||||
model=tokens[-1],
|
||||
)
|
||||
|
||||
|
||||
def _parse_four_node(parts: list[str]) -> SpiceComponent | None:
|
||||
"""Parse E, G, S: NAME N+ N- NC+ NC- VALUE/MODEL."""
|
||||
if len(parts) < 6:
|
||||
return None
|
||||
return SpiceComponent(
|
||||
name=parts[0], prefix=parts[0][0].upper(),
|
||||
nodes=[parts[1], parts[2], parts[3], parts[4]],
|
||||
value=" ".join(parts[5:]),
|
||||
)
|
||||
|
||||
|
||||
def _parse_ccxx(parts: list[str]) -> SpiceComponent | None:
|
||||
"""Parse F, H: NAME N+ N- VNAME GAIN."""
|
||||
if len(parts) < 5:
|
||||
return None
|
||||
return SpiceComponent(
|
||||
name=parts[0], prefix=parts[0][0].upper(),
|
||||
nodes=[parts[1], parts[2]],
|
||||
value=" ".join(parts[3:]),
|
||||
)
|
||||
|
||||
|
||||
def _parse_behavioral(parts: list[str]) -> SpiceComponent | None:
|
||||
"""Parse B: NAME N+ N- V=expr or I=expr."""
|
||||
if len(parts) < 4:
|
||||
return None
|
||||
return SpiceComponent(
|
||||
name=parts[0], prefix="B",
|
||||
nodes=[parts[1], parts[2]],
|
||||
value=" ".join(parts[3:]),
|
||||
)
|
||||
|
||||
|
||||
# ── Node Graph ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _build_node_map(
|
||||
components: list[SpiceComponent],
|
||||
) -> dict[str, list[tuple[SpiceComponent, int]]]:
|
||||
"""Build node_name → [(component, terminal_index)] adjacency map."""
|
||||
node_map: dict[str, list[tuple[SpiceComponent, int]]] = {}
|
||||
for comp in components:
|
||||
for i, node in enumerate(comp.nodes):
|
||||
node_map.setdefault(node, []).append((comp, i))
|
||||
return node_map
|
||||
|
||||
|
||||
# ── Main Loop Finder ───────────────────────────────────────────
|
||||
|
||||
|
||||
def _find_main_loop(
|
||||
components: list[SpiceComponent],
|
||||
node_map: dict[str, list[tuple[SpiceComponent, int]]],
|
||||
) -> list[SpiceComponent] | None:
|
||||
"""Find the main signal loop starting from a voltage/current source.
|
||||
|
||||
Traces through 2-terminal components from the source's positive terminal
|
||||
back to its negative terminal. Returns the ordered component list, or
|
||||
None if no clear loop exists.
|
||||
"""
|
||||
source = next((c for c in components if c.prefix == "V"), None)
|
||||
if source is None:
|
||||
source = next((c for c in components if c.prefix == "I"), None)
|
||||
if source is None:
|
||||
return None
|
||||
|
||||
start_node = source.nodes[0] # positive terminal
|
||||
end_node = source.nodes[1] # negative terminal (usually ground)
|
||||
|
||||
visited = {source.name}
|
||||
path = [source]
|
||||
current = start_node
|
||||
|
||||
for _ in range(len(components) + 1):
|
||||
# Reached the negative terminal?
|
||||
if current == end_node:
|
||||
return path
|
||||
if _is_ground(current) and _is_ground(end_node):
|
||||
return path
|
||||
|
||||
# Follow the next unvisited 2-terminal component
|
||||
found = False
|
||||
for comp, term_idx in node_map.get(current, []):
|
||||
if comp.name in visited:
|
||||
continue
|
||||
if len(comp.nodes) != 2:
|
||||
continue
|
||||
|
||||
other_idx = 1 - term_idx
|
||||
visited.add(comp.name)
|
||||
path.append(comp)
|
||||
current = comp.nodes[other_idx]
|
||||
found = True
|
||||
break
|
||||
|
||||
if not found:
|
||||
break
|
||||
|
||||
# Did we end at ground?
|
||||
if _is_ground(current) and _is_ground(end_node):
|
||||
return path
|
||||
|
||||
return path if len(path) > 1 else None
|
||||
|
||||
|
||||
# ── Value Formatting ───────────────────────────────────────────
|
||||
|
||||
|
||||
def _format_value(value: str) -> str:
|
||||
"""Clean up a SPICE value for schematic display."""
|
||||
if not value:
|
||||
return ""
|
||||
v = value.strip()
|
||||
# Simplify source specs for display
|
||||
for prefix in ("DC ", "AC ", "dc ", "ac "):
|
||||
if v.startswith(prefix):
|
||||
v = v[len(prefix):]
|
||||
break
|
||||
# Truncate transient specs (PULSE, SIN, etc.)
|
||||
if len(v) > 20:
|
||||
paren = v.find("(")
|
||||
if paren > 0:
|
||||
v = v[:paren].strip()
|
||||
else:
|
||||
v = v[:20] + "..."
|
||||
return v
|
||||
|
||||
|
||||
def _component_label(comp: SpiceComponent) -> str:
|
||||
"""Build a display label: name + value or model."""
|
||||
val = _format_value(comp.value)
|
||||
if val:
|
||||
return f"{comp.name}\n{val}"
|
||||
if comp.model:
|
||||
return f"{comp.name}\n{comp.model}"
|
||||
return comp.name
|
||||
|
||||
|
||||
# ── SchemDraw Element Mapping ──────────────────────────────────
|
||||
|
||||
|
||||
def _get_element(comp: SpiceComponent, models: dict[str, str]):
|
||||
"""Map a SPICE component prefix to the corresponding SchemDraw element."""
|
||||
import schemdraw.elements as elm
|
||||
|
||||
prefix = comp.prefix
|
||||
|
||||
if prefix == "R":
|
||||
return elm.Resistor()
|
||||
elif prefix == "C":
|
||||
return elm.Capacitor()
|
||||
elif prefix == "L":
|
||||
return elm.Inductor2()
|
||||
elif prefix == "V":
|
||||
return elm.SourceV()
|
||||
elif prefix == "I":
|
||||
return elm.SourceI()
|
||||
elif prefix == "D":
|
||||
return elm.Diode()
|
||||
elif prefix == "Q":
|
||||
model_type = models.get(comp.model, "").upper()
|
||||
if "PNP" in model_type:
|
||||
return elm.BjtPnp()
|
||||
return elm.BjtNpn()
|
||||
elif prefix == "M":
|
||||
model_type = models.get(comp.model, "").upper()
|
||||
if "PMOS" in model_type or model_type == "P":
|
||||
return elm.PFet()
|
||||
return elm.NFet()
|
||||
elif prefix in ("E", "G"):
|
||||
return elm.SourceControlledV()
|
||||
elif prefix in ("F", "H"):
|
||||
return elm.SourceControlledI()
|
||||
elif prefix == "S":
|
||||
return elm.Switch()
|
||||
else:
|
||||
return elm.RBox()
|
||||
|
||||
|
||||
# ── Loop Layout Renderer ──────────────────────────────────────
|
||||
|
||||
|
||||
def _render_loop(parsed: ParsedNetlist, loop: list[SpiceComponent]) -> str:
|
||||
"""Render a simple circuit loop as a clean rectangular schematic.
|
||||
|
||||
Layout: source on the left going up, components across the top going
|
||||
right, last component going down on the right, return wire along the
|
||||
bottom with a ground symbol.
|
||||
"""
|
||||
import schemdraw
|
||||
import schemdraw.elements as elm
|
||||
|
||||
d = schemdraw.Drawing(fontsize=12)
|
||||
|
||||
source = loop[0]
|
||||
path_comps = loop[1:]
|
||||
|
||||
if not path_comps:
|
||||
# Standalone source
|
||||
d.add(
|
||||
_get_element(source, parsed.models)
|
||||
.up()
|
||||
.label(_component_label(source), loc="left")
|
||||
)
|
||||
d.add(elm.Ground())
|
||||
return d.get_imagedata("svg").decode()
|
||||
|
||||
# Source on left side, going up
|
||||
src = d.add(
|
||||
_get_element(source, parsed.models)
|
||||
.up()
|
||||
.label(_component_label(source), loc="left")
|
||||
)
|
||||
|
||||
# All components except the last go right across the top
|
||||
for comp in path_comps[:-1]:
|
||||
d.add(
|
||||
_get_element(comp, parsed.models)
|
||||
.right()
|
||||
.label(_component_label(comp))
|
||||
)
|
||||
|
||||
# Last component goes down, ending at the same y-level as the source start
|
||||
last_comp = path_comps[-1]
|
||||
d.add(
|
||||
_get_element(last_comp, parsed.models)
|
||||
.down()
|
||||
.toy(src.start)
|
||||
.label(_component_label(last_comp), loc="right")
|
||||
)
|
||||
|
||||
# Return wire along the bottom back to source start
|
||||
d.add(elm.Line().to(src.start))
|
||||
|
||||
# Ground symbol at the source's negative terminal
|
||||
d.add(elm.Ground().at(src.start))
|
||||
|
||||
# Node label at source positive terminal
|
||||
node_label = source.nodes[0]
|
||||
if not _is_ground(node_label):
|
||||
d.add(
|
||||
elm.Dot(open=True)
|
||||
.at(src.end)
|
||||
.label(node_label, loc="left", fontsize=9)
|
||||
)
|
||||
|
||||
# Node label at junction between top components and the last (shunt) component
|
||||
if len(path_comps) >= 2:
|
||||
junction_node = path_comps[-2].nodes[1] if len(path_comps[-2].nodes) >= 2 else ""
|
||||
if junction_node and not _is_ground(junction_node):
|
||||
# Find the end of the second-to-last component (the junction point)
|
||||
# SchemDraw doesn't give us this easily, so skip for now
|
||||
pass
|
||||
|
||||
return d.get_imagedata("svg").decode()
|
||||
|
||||
|
||||
# ── Grid Layout Renderer ──────────────────────────────────────
|
||||
|
||||
|
||||
def _render_grid(parsed: ParsedNetlist) -> str:
|
||||
"""Render components in a labeled grid layout.
|
||||
|
||||
Used for complex circuits where topological layout isn't feasible.
|
||||
Components are arranged in columns by type with terminal node labels.
|
||||
"""
|
||||
import schemdraw
|
||||
|
||||
d = schemdraw.Drawing(fontsize=11)
|
||||
|
||||
# Group by role for logical ordering
|
||||
sources = [c for c in parsed.components if c.prefix in ("V", "I")]
|
||||
passives = [c for c in parsed.components if c.prefix in ("R", "C", "L")]
|
||||
active = [c for c in parsed.components if c.prefix in ("D", "Q", "M")]
|
||||
other = [
|
||||
c for c in parsed.components
|
||||
if c.prefix not in ("V", "I", "R", "C", "L", "D", "Q", "M")
|
||||
]
|
||||
ordered = sources + passives + active + other
|
||||
|
||||
cols = min(4, max(2, len(ordered)))
|
||||
x_spacing = 8
|
||||
y_spacing = 5
|
||||
|
||||
for i, comp in enumerate(ordered):
|
||||
row = i // cols
|
||||
col = i % cols
|
||||
x = col * x_spacing
|
||||
y = -row * y_spacing
|
||||
|
||||
elem = _get_element(comp, parsed.models)
|
||||
|
||||
# 3+ terminal devices (BJT, MOSFET) need special handling
|
||||
if comp.prefix in ("Q", "M") and len(comp.nodes) >= 3:
|
||||
placed = d.add(elem.at((x, y)).label(_component_label(comp)))
|
||||
_label_multiterminal(d, placed, comp)
|
||||
else:
|
||||
placed = d.add(
|
||||
elem.at((x, y)).right().label(_component_label(comp))
|
||||
)
|
||||
_label_two_terminal(d, placed, comp)
|
||||
|
||||
return d.get_imagedata("svg").decode()
|
||||
|
||||
|
||||
def _label_two_terminal(d, placed, comp: SpiceComponent) -> None:
|
||||
"""Add node-name labels to a placed 2-terminal element."""
|
||||
import schemdraw.elements as elm
|
||||
|
||||
if len(comp.nodes) >= 1:
|
||||
d.add(elm.Dot(radius=0.08).at(placed.start))
|
||||
d.add(
|
||||
elm.Label()
|
||||
.at(placed.start)
|
||||
.label(comp.nodes[0], loc="left", fontsize=8)
|
||||
)
|
||||
if len(comp.nodes) >= 2:
|
||||
d.add(elm.Dot(radius=0.08).at(placed.end))
|
||||
d.add(
|
||||
elm.Label()
|
||||
.at(placed.end)
|
||||
.label(comp.nodes[1], loc="right", fontsize=8)
|
||||
)
|
||||
|
||||
|
||||
def _label_multiterminal(d, placed, comp: SpiceComponent) -> None:
|
||||
"""Add node-name labels to a placed multi-terminal element (BJT/MOSFET)."""
|
||||
import schemdraw.elements as elm
|
||||
|
||||
terminal_names = {
|
||||
"Q": ["C", "B", "E"],
|
||||
"M": ["D", "G", "S"],
|
||||
}
|
||||
anchor_map_q = ["collector", "base", "emitter"]
|
||||
anchor_map_m = ["drain", "gate", "source"]
|
||||
|
||||
anchors = anchor_map_q if comp.prefix == "Q" else anchor_map_m
|
||||
labels = terminal_names.get(comp.prefix, [])
|
||||
|
||||
for j, node in enumerate(comp.nodes[:3]):
|
||||
if j < len(anchors) and hasattr(placed, anchors[j]):
|
||||
pos = getattr(placed, anchors[j])
|
||||
d.add(elm.Dot(radius=0.08).at(pos))
|
||||
label_text = f"{labels[j]}:{node}" if j < len(labels) else node
|
||||
d.add(
|
||||
elm.Label()
|
||||
.at(pos)
|
||||
.label(label_text, fontsize=8)
|
||||
)
|
||||
|
||||
|
||||
# ── Public API ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
def netlist_to_svg(netlist_text: str) -> str:
|
||||
"""Convert a SPICE netlist to an SVG schematic diagram.
|
||||
|
||||
Tries a clean loop layout for simple circuits (<=12 two-terminal
|
||||
components with a clear main path), falls back to a labeled grid
|
||||
for complex circuits with active devices.
|
||||
|
||||
Returns:
|
||||
SVG string.
|
||||
|
||||
Raises:
|
||||
ValueError: If no components could be parsed from the netlist.
|
||||
RuntimeError: If schemdraw is not installed.
|
||||
"""
|
||||
try:
|
||||
import schemdraw # noqa: F401
|
||||
except ImportError:
|
||||
raise RuntimeError(
|
||||
"schemdraw is required for schematic generation. "
|
||||
"Install with: pip install 'schemdraw>=0.19'"
|
||||
)
|
||||
|
||||
parsed = parse_netlist(netlist_text)
|
||||
|
||||
if not parsed.components:
|
||||
raise ValueError("No components found in netlist")
|
||||
|
||||
node_map = _build_node_map(parsed.components)
|
||||
|
||||
# Try loop layout for simple circuits with a clear main path
|
||||
two_terminal_only = all(len(c.nodes) == 2 for c in parsed.components)
|
||||
if two_terminal_only and len(parsed.components) <= 12:
|
||||
loop = _find_main_loop(parsed.components, node_map)
|
||||
if loop and len(loop) >= 2:
|
||||
try:
|
||||
return _render_loop(parsed, loop)
|
||||
except Exception as exc:
|
||||
logger.warning("Loop layout failed, using grid: %s", exc)
|
||||
|
||||
return _render_grid(parsed)
|
||||
@ -9,7 +9,7 @@ from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from spicebook.config import settings
|
||||
from spicebook.routers import notebooks, simulation, waveforms
|
||||
from spicebook.routers import notebooks, schematics, simulation, waveforms
|
||||
|
||||
logger = logging.getLogger("spicebook")
|
||||
|
||||
@ -27,9 +27,11 @@ def create_app() -> FastAPI:
|
||||
allow_origins=[
|
||||
"http://localhost:4321",
|
||||
"http://localhost:4322",
|
||||
"http://localhost:4326",
|
||||
"http://localhost:3000",
|
||||
"http://127.0.0.1:4321",
|
||||
"http://127.0.0.1:4322",
|
||||
"http://127.0.0.1:4326",
|
||||
"http://127.0.0.1:3000",
|
||||
],
|
||||
allow_credentials=True,
|
||||
@ -38,6 +40,7 @@ def create_app() -> FastAPI:
|
||||
)
|
||||
|
||||
application.include_router(notebooks.router)
|
||||
application.include_router(schematics.router)
|
||||
application.include_router(simulation.router)
|
||||
application.include_router(waveforms.router)
|
||||
|
||||
|
||||
66
backend/src/spicebook/routers/schematics.py
Normal file
66
backend/src/spicebook/routers/schematics.py
Normal file
@ -0,0 +1,66 @@
|
||||
"""Schematic generation endpoints."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from spicebook.config import settings
|
||||
from spicebook.engine.schematic import netlist_to_svg
|
||||
from spicebook.models.notebook import CellOutput, CellType
|
||||
from spicebook.storage.filesystem import load_notebook, save_notebook
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["schematics"])
|
||||
|
||||
|
||||
class SchematicResponse(BaseModel):
|
||||
svg: str | None = None
|
||||
success: bool
|
||||
error: str | None = None
|
||||
|
||||
|
||||
@router.post(
|
||||
"/notebooks/{notebook_id}/cells/{cell_id}/schematic",
|
||||
response_model=SchematicResponse,
|
||||
)
|
||||
async def generate_schematic(notebook_id: str, cell_id: str):
|
||||
"""Generate an SVG schematic from a SPICE cell's netlist."""
|
||||
nb = load_notebook(settings.notebook_dir, notebook_id)
|
||||
if nb is None:
|
||||
raise HTTPException(status_code=404, detail=f"Notebook '{notebook_id}' not found")
|
||||
|
||||
cell = next((c for c in nb.cells if c.id == cell_id), None)
|
||||
if cell is None:
|
||||
raise HTTPException(status_code=404, detail=f"Cell '{cell_id}' not found")
|
||||
|
||||
if cell.type != CellType.SPICE:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Cell is type '{cell.type.value}', not 'spice'",
|
||||
)
|
||||
|
||||
if not cell.source.strip():
|
||||
raise HTTPException(status_code=400, detail="Cell source is empty")
|
||||
|
||||
try:
|
||||
svg = netlist_to_svg(cell.source)
|
||||
except (ValueError, RuntimeError) as exc:
|
||||
return SchematicResponse(success=False, error=str(exc))
|
||||
except Exception as exc:
|
||||
return SchematicResponse(success=False, error=f"Schematic generation failed: {exc}")
|
||||
|
||||
# Save schematic output to cell (additive — preserve simulation outputs)
|
||||
now_iso = datetime.now(timezone.utc).isoformat()
|
||||
schematic_output = CellOutput(
|
||||
output_type="schematic",
|
||||
data={"svg": svg, "success": True},
|
||||
timestamp=now_iso,
|
||||
)
|
||||
|
||||
# Remove any previous schematic output, keep everything else
|
||||
cell.outputs = [o for o in cell.outputs if o.output_type != "schematic"]
|
||||
cell.outputs.append(schematic_output)
|
||||
|
||||
save_notebook(settings.notebook_dir, notebook_id, nb)
|
||||
|
||||
return SchematicResponse(svg=svg, success=True)
|
||||
@ -66,10 +66,10 @@ async def run_cell(notebook_id: str, cell_id: str):
|
||||
with tempfile.TemporaryDirectory(prefix="spicebook-sim-") as tmpdir:
|
||||
result = await engine.run(cell.source, Path(tmpdir))
|
||||
|
||||
# Save output to the cell in same format the frontend expects
|
||||
# Save output to the cell (preserve schematic outputs across re-runs)
|
||||
now_iso = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
cell.outputs = [CellOutput(
|
||||
sim_output = CellOutput(
|
||||
output_type="simulation_result" if result.success else "error",
|
||||
data={
|
||||
"success": result.success,
|
||||
@ -79,7 +79,9 @@ async def run_cell(notebook_id: str, cell_id: str):
|
||||
"elapsed_seconds": result.elapsed_seconds,
|
||||
},
|
||||
timestamp=now_iso,
|
||||
)]
|
||||
)
|
||||
preserved = [o for o in cell.outputs if o.output_type == "schematic"]
|
||||
cell.outputs = [sim_output] + preserved
|
||||
|
||||
save_notebook(settings.notebook_dir, notebook_id, nb)
|
||||
|
||||
|
||||
11
backend/uv.lock
generated
11
backend/uv.lock
generated
@ -446,6 +446,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/07/5bda6a85b220c64c65686bc85bd0bbb23b29c62b3a9f9433fa55f17cda93/ruff-0.15.1-py3-none-win_arm64.whl", hash = "sha256:5ff7d5f0f88567850f45081fac8f4ec212be8d0b963e385c3f7d0d2eb4899416", size = 10874604, upload-time = "2026-02-12T23:09:05.515Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schemdraw"
|
||||
version = "0.22"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6a/0e/3d2a9c2541ced42877e8b6049fb17c093329ccb81344529dc12a33d4c118/schemdraw-0.22.tar.gz", hash = "sha256:59d1fcfe817f93f8bfc37a3f4645186dc03316bbcfd9ac68804fd6cb3aa69f51", size = 10881350, upload-time = "2025-11-30T23:45:03.279Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/ab/b281071bc11555670a82c538e4be35e7f5ad5e4cb4de7ac356a05a4b2c6a/schemdraw-0.22-py3-none-any.whl", hash = "sha256:fb294fe086b89a7dc9bedce43dc39014a9b9e369864eab438f22009c9f0e1175", size = 148359, upload-time = "2025-11-30T23:42:04.794Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spicebook"
|
||||
version = "2026.2.13"
|
||||
@ -454,6 +463,7 @@ dependencies = [
|
||||
{ name = "fastapi" },
|
||||
{ name = "numpy" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "schemdraw" },
|
||||
{ name = "uvicorn", extra = ["standard"] },
|
||||
{ name = "websockets" },
|
||||
]
|
||||
@ -475,6 +485,7 @@ requires-dist = [
|
||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" },
|
||||
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23" },
|
||||
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.8" },
|
||||
{ name = "schemdraw", specifier = ">=0.19" },
|
||||
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.30.0" },
|
||||
{ name = "websockets", specifier = ">=12.0" },
|
||||
]
|
||||
|
||||
@ -5,6 +5,7 @@ import { useNotebookStore } from '../../../lib/notebook-store';
|
||||
import { CellToolbar } from '../toolbar/CellToolbar';
|
||||
import { SpiceEditor } from '../editor/SpiceEditor';
|
||||
import { WaveformViewer } from '../output/WaveformViewer';
|
||||
import { SchematicViewer } from '../output/SchematicViewer';
|
||||
import { SimulationLog } from '../output/SimulationLog';
|
||||
import { cn } from '../../../lib/cn';
|
||||
|
||||
@ -23,6 +24,7 @@ export function SpiceCell({ cell, isFirst, isLast }: SpiceCellProps) {
|
||||
deleteCell,
|
||||
moveCell,
|
||||
runCell,
|
||||
generateSchematic,
|
||||
} = useNotebookStore();
|
||||
|
||||
const isActive = activeCell === cell.id;
|
||||
@ -39,9 +41,15 @@ export function SpiceCell({ cell, isFirst, isLast }: SpiceCellProps) {
|
||||
runCell(cell.id);
|
||||
}, [cell.id, runCell]);
|
||||
|
||||
// Extract output data
|
||||
const lastOutput = cell.outputs.length > 0 ? cell.outputs[cell.outputs.length - 1] : null;
|
||||
const outputData = lastOutput?.data as {
|
||||
const handleGenerateSchematic = useCallback(() => {
|
||||
generateSchematic(cell.id);
|
||||
}, [cell.id, generateSchematic]);
|
||||
|
||||
// Extract simulation output
|
||||
const simOutput = cell.outputs.find(
|
||||
(o) => o.output_type === 'simulation_result' || o.output_type === 'error',
|
||||
);
|
||||
const outputData = simOutput?.data as {
|
||||
success?: boolean;
|
||||
waveform?: WaveformData | null;
|
||||
log?: string;
|
||||
@ -49,6 +57,10 @@ export function SpiceCell({ cell, isFirst, isLast }: SpiceCellProps) {
|
||||
elapsed_seconds?: number;
|
||||
} | null;
|
||||
|
||||
// Extract schematic output
|
||||
const schematicOutput = cell.outputs.find((o) => o.output_type === 'schematic');
|
||||
const schematicSvg = schematicOutput?.data?.svg as string | null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@ -66,12 +78,20 @@ export function SpiceCell({ cell, isFirst, isLast }: SpiceCellProps) {
|
||||
isFirst={isFirst}
|
||||
isLast={isLast}
|
||||
onRun={handleRun}
|
||||
onGenerateSchematic={handleGenerateSchematic}
|
||||
onDelete={() => deleteCell(cell.id)}
|
||||
onMoveUp={() => moveCell(cell.id, 'up')}
|
||||
onMoveDown={() => moveCell(cell.id, 'down')}
|
||||
/>
|
||||
|
||||
{/* Editor */}
|
||||
{/* Schematic — shown above the editor when generated */}
|
||||
{schematicSvg && (
|
||||
<div className="border-b border-slate-700/50">
|
||||
<SchematicViewer svg={schematicSvg} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* SPICE Editor — always visible */}
|
||||
<div className="min-h-[80px]">
|
||||
<SpiceEditor
|
||||
value={cell.source}
|
||||
@ -85,12 +105,12 @@ export function SpiceCell({ cell, isFirst, isLast }: SpiceCellProps) {
|
||||
<div className="border-t border-slate-700/50 px-4 py-3">
|
||||
<div className="flex items-center gap-2 text-sm text-blue-400">
|
||||
<div className="w-4 h-4 border-2 border-blue-400/30 border-t-blue-400 rounded-full animate-spin" />
|
||||
Running simulation...
|
||||
Processing...
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Outputs */}
|
||||
{/* Waveform + Simulation results — shown below the editor */}
|
||||
{!isRunning && outputData && (
|
||||
<div className="border-t border-slate-700/50">
|
||||
{/* Error banner */}
|
||||
|
||||
96
frontend/src/components/notebook/output/SchematicViewer.tsx
Normal file
96
frontend/src/components/notebook/output/SchematicViewer.tsx
Normal file
@ -0,0 +1,96 @@
|
||||
import { useState, useRef, useCallback } from 'react';
|
||||
import { ZoomIn, ZoomOut, Maximize2, Download } from 'lucide-react';
|
||||
|
||||
interface SchematicViewerProps {
|
||||
svg: string;
|
||||
}
|
||||
|
||||
const ZOOM_STEPS = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0];
|
||||
|
||||
export function SchematicViewer({ svg }: SchematicViewerProps) {
|
||||
const [zoomIndex, setZoomIndex] = useState(2); // Start at 100%
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const zoom = ZOOM_STEPS[zoomIndex];
|
||||
|
||||
const zoomIn = useCallback(() => {
|
||||
setZoomIndex((i) => Math.min(i + 1, ZOOM_STEPS.length - 1));
|
||||
}, []);
|
||||
|
||||
const zoomOut = useCallback(() => {
|
||||
setZoomIndex((i) => Math.max(i - 1, 0));
|
||||
}, []);
|
||||
|
||||
const fitToWidth = useCallback(() => {
|
||||
setZoomIndex(2); // Reset to 100%
|
||||
}, []);
|
||||
|
||||
const downloadSvg = useCallback(() => {
|
||||
const blob = new Blob([svg], { type: 'image/svg+xml' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'schematic.svg';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}, [svg]);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-1 px-3 py-1.5 border-b border-slate-700/50 bg-slate-800/50">
|
||||
<button
|
||||
onClick={zoomOut}
|
||||
disabled={zoomIndex === 0}
|
||||
className="p-1 rounded text-slate-400 hover:text-slate-200 hover:bg-slate-700/50 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
title="Zoom out"
|
||||
>
|
||||
<ZoomOut className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<span className="text-xs text-slate-500 tabular-nums w-10 text-center">
|
||||
{Math.round(zoom * 100)}%
|
||||
</span>
|
||||
<button
|
||||
onClick={zoomIn}
|
||||
disabled={zoomIndex === ZOOM_STEPS.length - 1}
|
||||
className="p-1 rounded text-slate-400 hover:text-slate-200 hover:bg-slate-700/50 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
title="Zoom in"
|
||||
>
|
||||
<ZoomIn className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={fitToWidth}
|
||||
className="p-1 rounded text-slate-400 hover:text-slate-200 hover:bg-slate-700/50 transition-colors"
|
||||
title="Reset zoom"
|
||||
>
|
||||
<Maximize2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<div className="flex-1" />
|
||||
<button
|
||||
onClick={downloadSvg}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded text-xs text-slate-400 hover:text-slate-200 hover:bg-slate-700/50 transition-colors"
|
||||
title="Download SVG"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
<span>SVG</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* SVG Canvas */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="overflow-auto bg-white rounded-b-lg"
|
||||
style={{ maxHeight: '500px' }}
|
||||
>
|
||||
<div
|
||||
className="p-4"
|
||||
style={{
|
||||
transform: `scale(${zoom})`,
|
||||
transformOrigin: 'top left',
|
||||
}}
|
||||
dangerouslySetInnerHTML={{ __html: svg }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -8,6 +8,7 @@ import {
|
||||
Zap,
|
||||
Code,
|
||||
Image,
|
||||
CircuitBoard,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '../../ui/Button';
|
||||
import { Badge } from '../../ui/Badge';
|
||||
@ -20,6 +21,7 @@ interface CellToolbarProps {
|
||||
isFirst: boolean;
|
||||
isLast: boolean;
|
||||
onRun?: () => void;
|
||||
onGenerateSchematic?: () => void;
|
||||
onDelete: () => void;
|
||||
onMoveUp: () => void;
|
||||
onMoveDown: () => void;
|
||||
@ -58,6 +60,7 @@ export function CellToolbar({
|
||||
isFirst,
|
||||
isLast,
|
||||
onRun,
|
||||
onGenerateSchematic,
|
||||
onDelete,
|
||||
onMoveUp,
|
||||
onMoveDown,
|
||||
@ -80,6 +83,19 @@ export function CellToolbar({
|
||||
{/* Cell ID */}
|
||||
<span className="text-[10px] text-slate-600 font-mono mr-2">{cellId}</span>
|
||||
|
||||
{/* Generate schematic (SPICE cells only) */}
|
||||
{onGenerateSchematic && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onGenerateSchematic}
|
||||
disabled={isRunning}
|
||||
title="Generate schematic from netlist"
|
||||
>
|
||||
<CircuitBoard className="w-3.5 h-3.5 text-cyan-400" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Run button (SPICE cells only) */}
|
||||
{onRun && (
|
||||
<Button
|
||||
|
||||
@ -170,3 +170,23 @@ export async function runCell(
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ── Schematics ────────────────────────────────────────────────
|
||||
|
||||
export interface SchematicResponse {
|
||||
svg: string | null;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function generateSchematic(
|
||||
notebookId: string,
|
||||
cellId: string,
|
||||
): Promise<SchematicResponse> {
|
||||
return request<SchematicResponse>(
|
||||
`/api/notebooks/${encodeURIComponent(notebookId)}/cells/${encodeURIComponent(cellId)}/schematic`,
|
||||
{
|
||||
method: 'POST',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@ -49,6 +49,7 @@ interface NotebookStore {
|
||||
setActiveCell: (cellId: string | null) => void;
|
||||
runCell: (cellId: string) => Promise<void>;
|
||||
runAllCells: () => Promise<void>;
|
||||
generateSchematic: (cellId: string) => Promise<void>;
|
||||
setCellOutput: (cellId: string, output: CellOutput) => void;
|
||||
updateTitle: (title: string) => void;
|
||||
updateEngine: (engine: string) => void;
|
||||
@ -222,9 +223,12 @@ export const useNotebookStore = create<NotebookStore>((set, get) => ({
|
||||
set({
|
||||
notebook: {
|
||||
...updatedNotebook,
|
||||
cells: updatedNotebook.cells.map((c) =>
|
||||
c.id === cellId ? { ...c, outputs: [output] } : c,
|
||||
),
|
||||
cells: updatedNotebook.cells.map((c) => {
|
||||
if (c.id !== cellId) return c;
|
||||
// Preserve schematic outputs across simulation re-runs
|
||||
const preserved = c.outputs.filter((o) => o.output_type === 'schematic');
|
||||
return { ...c, outputs: [output, ...preserved] };
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -245,9 +249,11 @@ export const useNotebookStore = create<NotebookStore>((set, get) => ({
|
||||
set({
|
||||
notebook: {
|
||||
...updatedNotebook,
|
||||
cells: updatedNotebook.cells.map((c) =>
|
||||
c.id === cellId ? { ...c, outputs: [output] } : c,
|
||||
),
|
||||
cells: updatedNotebook.cells.map((c) => {
|
||||
if (c.id !== cellId) return c;
|
||||
const preserved = c.outputs.filter((o) => o.output_type === 'schematic');
|
||||
return { ...c, outputs: [output, ...preserved] };
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -258,6 +264,57 @@ export const useNotebookStore = create<NotebookStore>((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
generateSchematic: async (cellId: string) => {
|
||||
const { notebook, notebookId, runningCells } = get();
|
||||
if (!notebook || !notebookId) return;
|
||||
|
||||
const cell = notebook.cells.find((c) => c.id === cellId);
|
||||
if (!cell || cell.type !== 'spice') return;
|
||||
|
||||
const newRunning = new Set(runningCells);
|
||||
newRunning.add(cellId);
|
||||
set({ runningCells: newRunning });
|
||||
|
||||
try {
|
||||
const result = await api.generateSchematic(notebookId, cellId);
|
||||
|
||||
if (result.success && result.svg) {
|
||||
const output: CellOutput = {
|
||||
output_type: 'schematic',
|
||||
data: { svg: result.svg, success: true },
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const updatedNotebook = get().notebook;
|
||||
if (updatedNotebook) {
|
||||
set({
|
||||
notebook: {
|
||||
...updatedNotebook,
|
||||
cells: updatedNotebook.cells.map((c) => {
|
||||
if (c.id !== cellId) return c;
|
||||
// Replace existing schematic, keep everything else
|
||||
const others = c.outputs.filter((o) => o.output_type !== 'schematic');
|
||||
return { ...c, outputs: [...others, output] };
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
} else {
|
||||
set({
|
||||
error: result.error || 'Schematic generation failed',
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
set({
|
||||
error: err instanceof Error ? err.message : 'Schematic generation failed',
|
||||
});
|
||||
} finally {
|
||||
const currentRunning = new Set(get().runningCells);
|
||||
currentRunning.delete(cellId);
|
||||
set({ runningCells: currentRunning });
|
||||
}
|
||||
},
|
||||
|
||||
runAllCells: async () => {
|
||||
const { notebook } = get();
|
||||
if (!notebook) return;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user