rocket-engine 0.0.2b1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- rocket_engine/__init__.py +8 -0
- rocket_engine/__main__.py +12 -0
- rocket_engine/adapters/README.md +54 -0
- rocket_engine/adapters/__init__.py +7 -0
- rocket_engine/adapters/api.py +39 -0
- rocket_engine/adapters/base.py +46 -0
- rocket_engine/adapters/cace/__init__.py +163 -0
- rocket_engine/adapters/circulax/README.md +10 -0
- rocket_engine/adapters/circulax/__init__.py +112 -0
- rocket_engine/adapters/circulax/_driver.py +190 -0
- rocket_engine/adapters/circulax/adapter.py +538 -0
- rocket_engine/adapters/circulax/results.py +291 -0
- rocket_engine/adapters/cli/README.md +34 -0
- rocket_engine/adapters/cli/__init__.py +305 -0
- rocket_engine/adapters/connectivity/__init__.py +196 -0
- rocket_engine/adapters/connectivity/results.py +214 -0
- rocket_engine/adapters/cosim/README.md +9 -0
- rocket_engine/adapters/cosim/__init__.py +1390 -0
- rocket_engine/adapters/cosim/results.py +214 -0
- rocket_engine/adapters/devsim/__init__.py +171 -0
- rocket_engine/adapters/digital_flow/README.md +9 -0
- rocket_engine/adapters/digital_flow/__init__.py +1121 -0
- rocket_engine/adapters/digital_flow/results.py +227 -0
- rocket_engine/adapters/digital_flow/state.py +462 -0
- rocket_engine/adapters/drc/README.klayout.md +7 -0
- rocket_engine/adapters/drc/README.md +7 -0
- rocket_engine/adapters/drc/__init__.py +1 -0
- rocket_engine/adapters/drc/klayout.py +952 -0
- rocket_engine/adapters/drc/magic.py +287 -0
- rocket_engine/adapters/drc/results.py +184 -0
- rocket_engine/adapters/drc/results_kl.py +188 -0
- rocket_engine/adapters/elmer/__init__.py +163 -0
- rocket_engine/adapters/envelope.py +120 -0
- rocket_engine/adapters/equivalence/__init__.py +156 -0
- rocket_engine/adapters/errors.py +181 -0
- rocket_engine/adapters/extract_magic/README.md +15 -0
- rocket_engine/adapters/extract_magic/__init__.py +425 -0
- rocket_engine/adapters/extract_magic/results.py +209 -0
- rocket_engine/adapters/formal_sby/README.md +7 -0
- rocket_engine/adapters/formal_sby/__init__.py +773 -0
- rocket_engine/adapters/formal_sby/formal_edge.py +195 -0
- rocket_engine/adapters/formal_sby/results.py +185 -0
- rocket_engine/adapters/gdsfactory/__init__.py +194 -0
- rocket_engine/adapters/gdsfactory/_driver.py +459 -0
- rocket_engine/adapters/gdsfactory/adapter.py +609 -0
- rocket_engine/adapters/gdsfactory/results.py +181 -0
- rocket_engine/adapters/hdl_sim/README.md +7 -0
- rocket_engine/adapters/hdl_sim/__init__.py +369 -0
- rocket_engine/adapters/hdl_sim/ghdl.py +155 -0
- rocket_engine/adapters/hdl_sim/results.py +178 -0
- rocket_engine/adapters/hdl_sim/verilator.py +167 -0
- rocket_engine/adapters/http/README.md +15 -0
- rocket_engine/adapters/http/__init__.py +458 -0
- rocket_engine/adapters/http/events.py +63 -0
- rocket_engine/adapters/ipc/README.md +9 -0
- rocket_engine/adapters/ipc/__init__.py +111 -0
- rocket_engine/adapters/lvs_netgen/README.md +20 -0
- rocket_engine/adapters/lvs_netgen/__init__.py +889 -0
- rocket_engine/adapters/lvs_netgen/results.py +168 -0
- rocket_engine/adapters/mcp/README.md +19 -0
- rocket_engine/adapters/mcp/__init__.py +85 -0
- rocket_engine/adapters/mcp/api-schema.json +6107 -0
- rocket_engine/adapters/mcp/pull.py +246 -0
- rocket_engine/adapters/mcp/server.py +794 -0
- rocket_engine/adapters/meep/__init__.py +167 -0
- rocket_engine/adapters/meow/README.md +7 -0
- rocket_engine/adapters/meow/__init__.py +190 -0
- rocket_engine/adapters/meow/_driver.py +227 -0
- rocket_engine/adapters/meow/_envelope.py +108 -0
- rocket_engine/adapters/meow/adapter.py +491 -0
- rocket_engine/adapters/meow/results.py +218 -0
- rocket_engine/adapters/mutation/__init__.py +192 -0
- rocket_engine/adapters/ngspice/README.md +28 -0
- rocket_engine/adapters/ngspice/__init__.py +627 -0
- rocket_engine/adapters/ngspice/results.py +215 -0
- rocket_engine/adapters/postlayout/README.md +28 -0
- rocket_engine/adapters/postlayout/__init__.py +17 -0
- rocket_engine/adapters/postlayout/adapter.py +1062 -0
- rocket_engine/adapters/postlayout/results.py +330 -0
- rocket_engine/adapters/postlayout/translate.py +71 -0
- rocket_engine/adapters/profile.py +220 -0
- rocket_engine/adapters/sax/README.md +23 -0
- rocket_engine/adapters/sax/__init__.py +516 -0
- rocket_engine/adapters/sax/_driver.py +148 -0
- rocket_engine/adapters/sax/results.py +291 -0
- rocket_engine/adapters/stdio_console.py +1151 -0
- rocket_engine/adapters/syntax/README.md +5 -0
- rocket_engine/adapters/syntax/__init__.py +366 -0
- rocket_engine/adapters/syntax/results.py +181 -0
- rocket_engine/adapters/synth_yosys/README.md +7 -0
- rocket_engine/adapters/synth_yosys/__init__.py +655 -0
- rocket_engine/adapters/synth_yosys/amaranth.py +120 -0
- rocket_engine/adapters/synth_yosys/ghdl.py +128 -0
- rocket_engine/adapters/synth_yosys/results.py +173 -0
- rocket_engine/adapters/timing_sta/README.md +9 -0
- rocket_engine/adapters/timing_sta/__init__.py +726 -0
- rocket_engine/adapters/view_sync/__init__.py +185 -0
- rocket_engine/adapters/view_sync/results.py +189 -0
- rocket_engine/adapters/vlsir/__init__.py +142 -0
- rocket_engine/adapters/xschem/__init__.py +440 -0
- rocket_engine/adapters/xschem/deck.py +266 -0
- rocket_engine/clients/__init__.py +5 -0
- rocket_engine/clients/runtime.py +255 -0
- rocket_engine/composition.py +2279 -0
- rocket_engine/execution/README.md +30 -0
- rocket_engine/execution/__init__.py +150 -0
- rocket_engine/execution/apig/__init__.py +1 -0
- rocket_engine/execution/apig/pool.py +463 -0
- rocket_engine/execution/apig/submit.py +124 -0
- rocket_engine/execution/block_runs_scope.py +479 -0
- rocket_engine/execution/coalesce.py +414 -0
- rocket_engine/execution/console/__init__.py +1 -0
- rocket_engine/execution/console/apply_session.py +318 -0
- rocket_engine/execution/console/lifecycle.py +265 -0
- rocket_engine/execution/dispatch.py +205 -0
- rocket_engine/execution/errors.py +31 -0
- rocket_engine/execution/ports.py +797 -0
- rocket_engine/execution/pyexec/__init__.py +1 -0
- rocket_engine/execution/pyexec/apply_context.py +547 -0
- rocket_engine/execution/pyexec/approvals.py +173 -0
- rocket_engine/execution/pyexec/runner.py +404 -0
- rocket_engine/execution/pyexec/support.py +464 -0
- rocket_engine/execution/python_exec.py +897 -0
- rocket_engine/execution/queue.py +85 -0
- rocket_engine/execution/replay_safe.py +601 -0
- rocket_engine/execution/routing/__init__.py +1 -0
- rocket_engine/execution/routing/calls.py +364 -0
- rocket_engine/execution/routing/namespace.py +785 -0
- rocket_engine/execution/routing/ports.py +102 -0
- rocket_engine/execution/routing/tools.py +346 -0
- rocket_engine/execution/runner.py +311 -0
- rocket_engine/execution/runtime.py +1149 -0
- rocket_engine/execution/service.py +279 -0
- rocket_engine/execution/worker.py +464 -0
- rocket_engine/interfaces/README.md +26 -0
- rocket_engine/interfaces/__init__.py +58 -0
- rocket_engine/interfaces/apig_assets.py +407 -0
- rocket_engine/interfaces/celllink.py +461 -0
- rocket_engine/interfaces/documents.py +92 -0
- rocket_engine/interfaces/envelopes.py +131 -0
- rocket_engine/interfaces/errors.py +16 -0
- rocket_engine/interfaces/layout.py +410 -0
- rocket_engine/interfaces/layout_schema.yaml +278 -0
- rocket_engine/interfaces/paths.py +223 -0
- rocket_engine/interfaces/pdks.py +550 -0
- rocket_engine/interfaces/ports.py +72 -0
- rocket_engine/interfaces/references.py +115 -0
- rocket_engine/interfaces/refs.py +85 -0
- rocket_engine/interfaces/runparams.py +304 -0
- rocket_engine/interfaces/setup.py +552 -0
- rocket_engine/interfaces/setup_catalogue.yaml +243 -0
- rocket_engine/interfaces/setup_probes.py +66 -0
- rocket_engine/interfaces/vocabulary.py +58 -0
- rocket_engine/interfaces/yaml_dump.py +31 -0
- rocket_engine/library/README.md +22 -0
- rocket_engine/library/__init__.py +13 -0
- rocket_engine/library/apig_catalog.py +371 -0
- rocket_engine/library/catalog.py +185 -0
- rocket_engine/library/errors.py +16 -0
- rocket_engine/mutate/__init__.py +776 -0
- rocket_engine/mutate/operators.py +700 -0
- rocket_engine/ops/__init__.py +1 -0
- rocket_engine/ops/deferred.py +279 -0
- rocket_engine/ops/editors.py +404 -0
- rocket_engine/ops/evidence.py +194 -0
- rocket_engine/ops/gates.py +262 -0
- rocket_engine/ops/ports.py +76 -0
- rocket_engine/ops/queries.py +417 -0
- rocket_engine/ops/runs.py +553 -0
- rocket_engine/ops/submit.py +614 -0
- rocket_engine/project/README.md +27 -0
- rocket_engine/project/__init__.py +6 -0
- rocket_engine/project/canvas/__init__.py +55 -0
- rocket_engine/project/canvas/compile.py +109 -0
- rocket_engine/project/canvas/integrate.py +48 -0
- rocket_engine/project/canvas/schema.py +241 -0
- rocket_engine/project/canvas/templates.py +46 -0
- rocket_engine/project/canvas/validate.py +603 -0
- rocket_engine/project/edit/__init__.py +5 -0
- rocket_engine/project/edit/cas.py +87 -0
- rocket_engine/project/edit/transactions.py +282 -0
- rocket_engine/project/errors.py +16 -0
- rocket_engine/project/frontend13.py +685 -0
- rocket_engine/project/geometry/__init__.py +23 -0
- rocket_engine/project/geometry/ops.py +451 -0
- rocket_engine/project/geometry/snap.py +103 -0
- rocket_engine/project/ip/__init__.py +1 -0
- rocket_engine/project/ip/blocks.py +195 -0
- rocket_engine/project/ip/entry.schema.json +78 -0
- rocket_engine/project/ip/lifecycle.py +402 -0
- rocket_engine/project/ip/verbs.py +123 -0
- rocket_engine/project/layers/__init__.py +6 -0
- rocket_engine/project/layers/bindings.py +115 -0
- rocket_engine/project/layers/catalog.py +228 -0
- rocket_engine/project/layers/presets.py +43 -0
- rocket_engine/project/ledger/__init__.py +1 -0
- rocket_engine/project/ledger/docs.py +167 -0
- rocket_engine/project/ledger/entities.py +176 -0
- rocket_engine/project/ledger/events.py +29 -0
- rocket_engine/project/ledger/runs.py +257 -0
- rocket_engine/project/ledger/shared.py +174 -0
- rocket_engine/project/ledger/snapshots.py +385 -0
- rocket_engine/project/ledger/wires.py +552 -0
- rocket_engine/project/process/__init__.py +14 -0
- rocket_engine/project/process/model.py +193 -0
- rocket_engine/project/process/recipes.py +121 -0
- rocket_engine/project/schematic/__init__.py +1 -0
- rocket_engine/project/schematic/hierarchy.py +312 -0
- rocket_engine/project/schematic/instances.py +393 -0
- rocket_engine/project/schematic/sheets.py +198 -0
- rocket_engine/project/service.py +2941 -0
- rocket_engine/project/symbols/__init__.py +1 -0
- rocket_engine/project/symbols/cells.py +324 -0
- rocket_engine/project/symbols/linkage.py +356 -0
- rocket_engine/project/symbols/symbols.py +352 -0
- rocket_engine/project/templates/__init__.py +303 -0
- rocket_engine/project/templates/errors.py +25 -0
- rocket_engine/project/testbench/__init__.py +49 -0
- rocket_engine/project/testbench/blocks.py +619 -0
- rocket_engine/project/testbench/templates.py +134 -0
- rocket_engine/stores/README.md +12 -0
- rocket_engine/stores/__init__.py +6 -0
- rocket_engine/stores/ecs.py +1508 -0
- rocket_engine/stores/layers.py +1023 -0
- rocket_engine/stores/settings.py +1188 -0
- rocket_engine/stores/wireplace.py +1324 -0
- rocket_engine/workflows/README.md +18 -0
- rocket_engine/workflows/__init__.py +53 -0
- rocket_engine/workflows/capability_plan.py +102 -0
- rocket_engine/workflows/completion.py +228 -0
- rocket_engine/workflows/connectgraph.py +734 -0
- rocket_engine/workflows/connectgraph_emit.py +336 -0
- rocket_engine/workflows/gates.py +144 -0
- rocket_engine/workflows/photonic_wire.py +76 -0
- rocket_engine/workflows/stages.py +324 -0
- rocket_engine/workflows/viewsync.py +399 -0
- rocket_engine-0.0.2b1.dist-info/METADATA +200 -0
- rocket_engine-0.0.2b1.dist-info/RECORD +241 -0
- rocket_engine-0.0.2b1.dist-info/WHEEL +4 -0
- rocket_engine-0.0.2b1.dist-info/entry_points.txt +2 -0
- rocket_engine-0.0.2b1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,627 @@
|
|
|
1
|
+
"""Ngspice SPICE solver adapter: run_spice behind the ToolAdapter port.
|
|
2
|
+
|
|
3
|
+
Corner-deck protocol (staged into the workdir): preamble
|
|
4
|
+
(.param/.option/.temp/.lib) + netlist bodies (bare `.end` lines
|
|
5
|
+
stripped, else ngspice stops before .control) + generated
|
|
6
|
+
.control (save all, analyses, let/print meas_*) — plus a
|
|
7
|
+
.spiceinit (neutral base lines plus the PDK profile extras),
|
|
8
|
+
then run `ngspice -b -o ngspice.log ngspice_deck.spice` under a
|
|
9
|
+
timeout and parse `meas_* = value` lines. Verdicts always come
|
|
10
|
+
from parsed output, never the exit code: bound misses are FAIL,
|
|
11
|
+
a sim that yields no values synthesizes sim_fail violations, and
|
|
12
|
+
only a missing binary raises missing_dependency.
|
|
13
|
+
|
|
14
|
+
Simulator needs resolve through PDK profile records
|
|
15
|
+
(rocket_engine.adapters.profile, data under docs/pdk/<lane>/):
|
|
16
|
+
model relpaths and .spiceinit lines come from the lane profile,
|
|
17
|
+
never from adapter special-cases. PDK models resolve through the
|
|
18
|
+
PDK store (resolve_deck): a wrong revision, an unknown model
|
|
19
|
+
relpath, or a tampered model file fails loudly in preflight —
|
|
20
|
+
the adapter never silently sims against substitute models.
|
|
21
|
+
|
|
22
|
+
Tool resolution: an explicit job `tool_dir` containing ngspice
|
|
23
|
+
wins, else PATH. Where the tools live on disk is B10 scope.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
import math
|
|
27
|
+
import re
|
|
28
|
+
import shutil
|
|
29
|
+
import subprocess
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
from ...execution.errors import ExecutionError
|
|
33
|
+
from ...execution.runner import tool_roots
|
|
34
|
+
from ...interfaces import ToolAdapter
|
|
35
|
+
from ...interfaces.pdks import resolve_deck
|
|
36
|
+
from ..errors import AdapterError
|
|
37
|
+
from ..profile import default_model_rel, render_spiceinit, spiceinit_for
|
|
38
|
+
|
|
39
|
+
OPERATION = "run_spice"
|
|
40
|
+
_TIMEOUT_S = 120
|
|
41
|
+
_DECK_NAME = "ngspice_deck.spice"
|
|
42
|
+
_LOG_NAME = "ngspice.log"
|
|
43
|
+
_WAVEFORM_PREFIX = "waveforms-"
|
|
44
|
+
_SWEEPS = ("ac", "dc", "tran", "noise")
|
|
45
|
+
_SPICEINIT_NAME = ".spiceinit"
|
|
46
|
+
|
|
47
|
+
# Mirrors corner-run.py MEAS_RE (leading whitespace tolerated).
|
|
48
|
+
_MEAS_RE = re.compile(
|
|
49
|
+
r"^\s*meas_([A-Za-z0-9_]+)\s*=\s*"
|
|
50
|
+
r"(-?[0-9]*\.?[0-9]+(?:[eE][-+]?[0-9]+)?)",
|
|
51
|
+
re.M,
|
|
52
|
+
)
|
|
53
|
+
_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
54
|
+
_CORNER_RE = re.compile(r"^[A-Za-z0-9_]+$")
|
|
55
|
+
_PARAM_KEY_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
56
|
+
_ERROR_RE = re.compile(r"error", re.IGNORECASE)
|
|
57
|
+
|
|
58
|
+
#: .spiceinit staging honors the PDK profile: neutral base lines plus
|
|
59
|
+
#: the lane extras (rocket_engine.adapters.profile). Model-free runs
|
|
60
|
+
#: (no pdk pin) stage the base only.
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _is_number(value):
|
|
64
|
+
return (
|
|
65
|
+
isinstance(value, (int, float))
|
|
66
|
+
and not isinstance(value, bool)
|
|
67
|
+
and math.isfinite(value)
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _single_line(value):
|
|
72
|
+
return (
|
|
73
|
+
isinstance(value, str)
|
|
74
|
+
and value.strip()
|
|
75
|
+
and "\n" not in value
|
|
76
|
+
and "\r" not in value
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _check_measurements(measurements):
|
|
81
|
+
if not isinstance(measurements, list) or not measurements:
|
|
82
|
+
raise AdapterError(
|
|
83
|
+
"invalid_command", "run_spice needs at least one measurement."
|
|
84
|
+
)
|
|
85
|
+
clean = []
|
|
86
|
+
for item in measurements:
|
|
87
|
+
if not isinstance(item, dict):
|
|
88
|
+
raise AdapterError(
|
|
89
|
+
"invalid_command", f"Bad measurement {item!r} (not a mapping)."
|
|
90
|
+
)
|
|
91
|
+
name = item.get("name")
|
|
92
|
+
expr = item.get("expr")
|
|
93
|
+
if not isinstance(name, str) or not _NAME_RE.match(name):
|
|
94
|
+
raise AdapterError(
|
|
95
|
+
"invalid_command", f"Bad measurement name: {name!r}."
|
|
96
|
+
)
|
|
97
|
+
if not _single_line(expr):
|
|
98
|
+
raise AdapterError(
|
|
99
|
+
"invalid_command",
|
|
100
|
+
f"Measurement {name!r} needs a one-line expr.",
|
|
101
|
+
)
|
|
102
|
+
bounds = {
|
|
103
|
+
key: item[key]
|
|
104
|
+
for key in ("min", "max")
|
|
105
|
+
if item.get(key) is not None
|
|
106
|
+
}
|
|
107
|
+
if not bounds or not all(_is_number(v) for v in bounds.values()):
|
|
108
|
+
raise AdapterError(
|
|
109
|
+
"invalid_command",
|
|
110
|
+
f"Measurement {name!r} needs numeric min and/or max.",
|
|
111
|
+
)
|
|
112
|
+
if (
|
|
113
|
+
"min" in bounds
|
|
114
|
+
and "max" in bounds
|
|
115
|
+
and bounds["min"] > bounds["max"]
|
|
116
|
+
):
|
|
117
|
+
raise AdapterError(
|
|
118
|
+
"invalid_command",
|
|
119
|
+
f"Measurement {name!r} has min above max.",
|
|
120
|
+
)
|
|
121
|
+
clean.append(
|
|
122
|
+
{
|
|
123
|
+
"name": name,
|
|
124
|
+
"expr": expr.strip(),
|
|
125
|
+
"unit": item.get("unit", ""),
|
|
126
|
+
"min": bounds.get("min"),
|
|
127
|
+
"max": bounds.get("max"),
|
|
128
|
+
}
|
|
129
|
+
)
|
|
130
|
+
return clean
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _check_control_lines(values, kind):
|
|
134
|
+
clean = []
|
|
135
|
+
for value in values or []:
|
|
136
|
+
if not _single_line(value) or value.strip().startswith("."):
|
|
137
|
+
raise AdapterError(
|
|
138
|
+
"invalid_command", f"Bad {kind} line: {value!r}."
|
|
139
|
+
)
|
|
140
|
+
clean.append(value.strip())
|
|
141
|
+
return clean
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _check_params(params):
|
|
145
|
+
clean = {}
|
|
146
|
+
for key, value in (params or {}).items():
|
|
147
|
+
if not isinstance(key, str) or not _PARAM_KEY_RE.match(key):
|
|
148
|
+
raise AdapterError("invalid_command", f"Bad .param name: {key!r}.")
|
|
149
|
+
if isinstance(value, bool) or not isinstance(value, (int, float, str)):
|
|
150
|
+
raise AdapterError(
|
|
151
|
+
"invalid_command", f"Bad .param value for {key!r}: {value!r}."
|
|
152
|
+
)
|
|
153
|
+
text = str(value)
|
|
154
|
+
if not text or any(char.isspace() for char in text) or '"' in text:
|
|
155
|
+
raise AdapterError(
|
|
156
|
+
"invalid_command", f"Bad .param value for {key!r}: {value!r}."
|
|
157
|
+
)
|
|
158
|
+
clean[key] = text
|
|
159
|
+
return clean
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _strip_end_lines(text):
|
|
163
|
+
return "\n".join(
|
|
164
|
+
line for line in text.splitlines() if line.strip().lower() != ".end"
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _read_source(path):
|
|
169
|
+
try:
|
|
170
|
+
return Path(path).read_text(encoding="utf-8")
|
|
171
|
+
except OSError as exc:
|
|
172
|
+
raise AdapterError(
|
|
173
|
+
"invalid_reference", f"Netlist unreadable: {path} ({exc})."
|
|
174
|
+
) from exc
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def build_deck(title, corner, temp_c, params, options, model_lib, bodies,
|
|
178
|
+
analyses, measurements):
|
|
179
|
+
"""Render the corner deck (preamble + bodies + .control).
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
title: Deck title line (without the `*`).
|
|
183
|
+
corner: Model corner named on the .lib line.
|
|
184
|
+
temp_c: Temperature in C for the .temp line.
|
|
185
|
+
params: Cleaned .param name/value mapping.
|
|
186
|
+
options: Cleaned .option lines.
|
|
187
|
+
model_lib: Store model library path, or None for model-free.
|
|
188
|
+
bodies: Netlist body texts (.end lines already stripped).
|
|
189
|
+
analyses: .control analysis lines (op when empty).
|
|
190
|
+
measurements: Cleaned measurement items (let/print pairs).
|
|
191
|
+
|
|
192
|
+
Returns:
|
|
193
|
+
Rendered corner deck text.
|
|
194
|
+
"""
|
|
195
|
+
head = [
|
|
196
|
+
f"* {title}",
|
|
197
|
+
f"* corner: {corner} temp: {temp_c}C "
|
|
198
|
+
"(generated by rocket_engine ngspice adapter)",
|
|
199
|
+
]
|
|
200
|
+
for key, value in params.items():
|
|
201
|
+
head.append(f".param {key}={value}")
|
|
202
|
+
for opt in options:
|
|
203
|
+
head.append(f".option {opt}")
|
|
204
|
+
head.append(f".temp {temp_c}")
|
|
205
|
+
if model_lib is not None:
|
|
206
|
+
head.append(f'.lib "{model_lib}" {corner}')
|
|
207
|
+
control = [".control", "save all", "set filetype=ascii"]
|
|
208
|
+
# Each sweep's vectors go to an ASCII rawfile right after it runs: the
|
|
209
|
+
# dataset behind the verdict, for clients to plot.
|
|
210
|
+
sweeps = 0
|
|
211
|
+
for line in analyses or ["op"]:
|
|
212
|
+
control.append(line)
|
|
213
|
+
kind = line.split()[0].lower() if line.split() else ""
|
|
214
|
+
if kind in _SWEEPS:
|
|
215
|
+
sweeps += 1
|
|
216
|
+
control.append(f"write {_WAVEFORM_PREFIX}{sweeps}-{kind}.raw")
|
|
217
|
+
for item in measurements:
|
|
218
|
+
control.append(f"let meas_{item['name']} = {item['expr']}")
|
|
219
|
+
for item in measurements:
|
|
220
|
+
control.append(f"print meas_{item['name']}")
|
|
221
|
+
control.extend(["quit", ".endc", ".end", ""])
|
|
222
|
+
return "\n".join(head + bodies + control)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
class NgspiceAdapter(ToolAdapter):
|
|
226
|
+
"""run_spice via ngspice: corner sims with measured bounds.
|
|
227
|
+
|
|
228
|
+
Serves the run_spice operation with deck.tool `ngspice` (the
|
|
229
|
+
only tool). Stages a corner deck (preamble + netlist bodies +
|
|
230
|
+
generated .control), runs it in batch, and parses meas_* values
|
|
231
|
+
against min/max bounds; bound misses are FAIL, valueless sims
|
|
232
|
+
synthesize sim_fail violations.
|
|
233
|
+
|
|
234
|
+
Job keys:
|
|
235
|
+
sources (required): Netlist source paths staged as bodies.
|
|
236
|
+
measurements (required): [{name, expr, unit?, min?, max?}]
|
|
237
|
+
with numeric min and/or max each.
|
|
238
|
+
analyses: .control analysis lines (default op).
|
|
239
|
+
options: .option lines (default [wnflag=1]).
|
|
240
|
+
params: .param name/value mapping.
|
|
241
|
+
corner: Model corner (default tt).
|
|
242
|
+
temp_c: Temperature in C (default 27).
|
|
243
|
+
title: Deck title line.
|
|
244
|
+
pdk: {id, revision, model_rel?, engine_root?} pin for PDK
|
|
245
|
+
models via the store; omitted sims model-free.
|
|
246
|
+
timeout_s: Sim budget in seconds (default 120).
|
|
247
|
+
tool_dir: Directory holding ngspice; PATH otherwise.
|
|
248
|
+
deck: Optional mapping; deck.tool must be ngspice here.
|
|
249
|
+
workdir: Run directory (default ".").
|
|
250
|
+
|
|
251
|
+
Args:
|
|
252
|
+
runner: Optional isolated runner; direct subprocess when None.
|
|
253
|
+
|
|
254
|
+
Caution: Verdicts always come from parsed output, never the exit
|
|
255
|
+
code — and PDK models resolve through the store, so a wrong
|
|
256
|
+
revision or tampered model fails loudly instead of simming
|
|
257
|
+
against substitutes.
|
|
258
|
+
"""
|
|
259
|
+
|
|
260
|
+
def __init__(self, runner=None):
|
|
261
|
+
self._runner = runner
|
|
262
|
+
|
|
263
|
+
def capabilities(self, target):
|
|
264
|
+
"""Report the served operation and tool name.
|
|
265
|
+
|
|
266
|
+
Args:
|
|
267
|
+
target: Target the capabilities are reported for
|
|
268
|
+
(accepted, unused — one corner per job).
|
|
269
|
+
|
|
270
|
+
Returns:
|
|
271
|
+
Capability mapping with operations and tool.
|
|
272
|
+
"""
|
|
273
|
+
return {"operations": [OPERATION], "tool": "ngspice"}
|
|
274
|
+
|
|
275
|
+
def preflight(self, job):
|
|
276
|
+
"""Check operation, inputs, deck shape, PDK pin, and binary.
|
|
277
|
+
|
|
278
|
+
Cheap deterministic checks first, PDK resolution next (a
|
|
279
|
+
wrong rev must fail before any sim), tool resolution last.
|
|
280
|
+
|
|
281
|
+
Args:
|
|
282
|
+
job: Solver job with operation run_spice plus sources,
|
|
283
|
+
measurements, analyses, and deck inputs.
|
|
284
|
+
|
|
285
|
+
Returns:
|
|
286
|
+
Checked mapping with the resolved ngspice path,
|
|
287
|
+
sources, measurements, analyses, options, params,
|
|
288
|
+
corner, temp_c, title, model_lib, and timeout_s.
|
|
289
|
+
|
|
290
|
+
Raises:
|
|
291
|
+
AdapterError: unsupported_operation for another
|
|
292
|
+
operation, invalid_command for bad inputs (timeout,
|
|
293
|
+
measurements, analyses, options, params, corner,
|
|
294
|
+
temp_c, title), invalid_reference for missing
|
|
295
|
+
sources, store codes (unknown_pdk, pdk_drift, ...)
|
|
296
|
+
for a bad PDK pin, missing_dependency for an
|
|
297
|
+
unresolvable ngspice.
|
|
298
|
+
"""
|
|
299
|
+
# Cheap deterministic checks first: operation, timeout,
|
|
300
|
+
# sources, measurements, deck shape. PDK resolution next
|
|
301
|
+
# (wrong rev must fail before any sim). Tool resolution
|
|
302
|
+
# last, since it depends on the machine.
|
|
303
|
+
if job.get("operation") != OPERATION:
|
|
304
|
+
raise AdapterError(
|
|
305
|
+
"unsupported_operation",
|
|
306
|
+
f"ngspice handles {OPERATION}, got {job.get('operation')}.",
|
|
307
|
+
)
|
|
308
|
+
timeout = job.get("timeout_s", _TIMEOUT_S)
|
|
309
|
+
if (
|
|
310
|
+
not isinstance(timeout, (int, float))
|
|
311
|
+
or isinstance(timeout, bool)
|
|
312
|
+
or not timeout > 0
|
|
313
|
+
):
|
|
314
|
+
raise AdapterError("invalid_command", f"Bad timeout_s: {timeout!r}.")
|
|
315
|
+
sources = job.get("sources") or []
|
|
316
|
+
missing = [s for s in sources if not Path(s).is_file()]
|
|
317
|
+
if not sources or missing:
|
|
318
|
+
raise AdapterError(
|
|
319
|
+
"invalid_reference", f"Sources not found: {missing or sources}."
|
|
320
|
+
)
|
|
321
|
+
measurements = _check_measurements(job.get("measurements"))
|
|
322
|
+
analyses = _check_control_lines(job.get("analyses"), "analyses")
|
|
323
|
+
options = _check_control_lines(
|
|
324
|
+
job.get("options", ["wnflag=1"]), "options"
|
|
325
|
+
)
|
|
326
|
+
params = _check_params(job.get("params"))
|
|
327
|
+
corner = job.get("corner", "tt")
|
|
328
|
+
if not isinstance(corner, str) or not _CORNER_RE.match(corner):
|
|
329
|
+
raise AdapterError("invalid_command", f"Bad corner: {corner!r}.")
|
|
330
|
+
temp_c = job.get("temp_c", 27)
|
|
331
|
+
if not _is_number(temp_c):
|
|
332
|
+
raise AdapterError("invalid_command", f"Bad temp_c: {temp_c!r}.")
|
|
333
|
+
title = job.get("title", "rocket_engine run_spice")
|
|
334
|
+
if not _single_line(title):
|
|
335
|
+
raise AdapterError("invalid_command", f"Bad title: {title!r}.")
|
|
336
|
+
model_lib = self._resolve_model(job.get("pdk"))
|
|
337
|
+
checked = {
|
|
338
|
+
"ngspice": self._resolve(job),
|
|
339
|
+
"sources": list(sources),
|
|
340
|
+
"measurements": measurements,
|
|
341
|
+
"analyses": analyses,
|
|
342
|
+
"options": options,
|
|
343
|
+
"params": params,
|
|
344
|
+
"corner": corner,
|
|
345
|
+
"temp_c": temp_c,
|
|
346
|
+
"title": title.strip(),
|
|
347
|
+
"model_lib": model_lib,
|
|
348
|
+
"timeout_s": timeout,
|
|
349
|
+
}
|
|
350
|
+
if self._runner is not None:
|
|
351
|
+
try:
|
|
352
|
+
self._runner.preflight()
|
|
353
|
+
except ExecutionError as exc:
|
|
354
|
+
raise AdapterError(exc.code, str(exc)) from exc
|
|
355
|
+
return checked
|
|
356
|
+
|
|
357
|
+
def execute(self, job):
|
|
358
|
+
"""Stage the corner deck plus .spiceinit, then run ngspice.
|
|
359
|
+
|
|
360
|
+
Args:
|
|
361
|
+
job: Solver job with operation run_spice plus sources,
|
|
362
|
+
measurements, analyses, and deck inputs.
|
|
363
|
+
|
|
364
|
+
Returns:
|
|
365
|
+
Native output with exit_code, stdout/stderr, log, deck,
|
|
366
|
+
sources, and measurements.
|
|
367
|
+
|
|
368
|
+
Raises:
|
|
369
|
+
AdapterError: invalid_reference for a missing workdir
|
|
370
|
+
or unreadable netlist, runner_unavailable on a sim
|
|
371
|
+
timeout, plus the preflight codes (execute
|
|
372
|
+
re-checks first).
|
|
373
|
+
"""
|
|
374
|
+
checked = self.preflight(job)
|
|
375
|
+
workdir = job.get("workdir", ".")
|
|
376
|
+
if not Path(workdir).is_dir():
|
|
377
|
+
raise AdapterError(
|
|
378
|
+
"invalid_reference", f"Workdir not found: {workdir}."
|
|
379
|
+
)
|
|
380
|
+
workdir_path = Path(workdir)
|
|
381
|
+
bodies = [
|
|
382
|
+
_strip_end_lines(_read_source(path)) for path in checked["sources"]
|
|
383
|
+
]
|
|
384
|
+
deck = build_deck(
|
|
385
|
+
checked["title"],
|
|
386
|
+
checked["corner"],
|
|
387
|
+
checked["temp_c"],
|
|
388
|
+
checked["params"],
|
|
389
|
+
checked["options"],
|
|
390
|
+
checked["model_lib"],
|
|
391
|
+
bodies,
|
|
392
|
+
checked["analyses"],
|
|
393
|
+
checked["measurements"],
|
|
394
|
+
)
|
|
395
|
+
# Rawfiles from an earlier run in this workdir must not pass as this run's data.
|
|
396
|
+
for stale in workdir_path.glob(f"{_WAVEFORM_PREFIX}*.raw"):
|
|
397
|
+
stale.unlink()
|
|
398
|
+
(workdir_path / _DECK_NAME).write_text(deck, encoding="utf-8")
|
|
399
|
+
pdk_pin = job.get("pdk")
|
|
400
|
+
pdk_id = pdk_pin.get("id") if isinstance(pdk_pin, dict) else None
|
|
401
|
+
spiceinit = render_spiceinit(spiceinit_for(pdk_id))
|
|
402
|
+
(workdir_path / _SPICEINIT_NAME).write_text(
|
|
403
|
+
spiceinit, encoding="utf-8"
|
|
404
|
+
)
|
|
405
|
+
roots = tool_roots([checked["ngspice"]])
|
|
406
|
+
proc = self._run(
|
|
407
|
+
[checked["ngspice"], "-b", "-o", _LOG_NAME, _DECK_NAME],
|
|
408
|
+
workdir,
|
|
409
|
+
checked["timeout_s"],
|
|
410
|
+
roots,
|
|
411
|
+
)
|
|
412
|
+
log_path = workdir_path / _LOG_NAME
|
|
413
|
+
log_text = log_path.read_text(encoding="utf-8", errors="replace") if (
|
|
414
|
+
log_path.is_file()
|
|
415
|
+
) else ""
|
|
416
|
+
return {
|
|
417
|
+
"exit_code": proc.returncode,
|
|
418
|
+
"stdout": proc.stdout,
|
|
419
|
+
"stderr": proc.stderr,
|
|
420
|
+
"log": log_text,
|
|
421
|
+
"deck": deck,
|
|
422
|
+
"waveforms": [
|
|
423
|
+
{"analysis": path.stem.rsplit("-", 1)[-1], "path": str(path)}
|
|
424
|
+
for path in sorted(workdir_path.glob(f"{_WAVEFORM_PREFIX}*.raw"))
|
|
425
|
+
],
|
|
426
|
+
"sources": checked["sources"],
|
|
427
|
+
"measurements": checked["measurements"],
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
def parse(self, native_output):
|
|
431
|
+
"""Parse meas_* values against bounds with engine margins.
|
|
432
|
+
|
|
433
|
+
Bound misses are FAIL (margin always engine-computed, never
|
|
434
|
+
left to the caller); expected measurements with no value
|
|
435
|
+
synthesize sim_fail violations quoting ngspice's error text.
|
|
436
|
+
|
|
437
|
+
Args:
|
|
438
|
+
native_output: Native output with stdout/stderr/log,
|
|
439
|
+
sources, and the expected measurements.
|
|
440
|
+
|
|
441
|
+
Returns:
|
|
442
|
+
{total, violations[], measurements[], source} with
|
|
443
|
+
value, unit, target, margin, and status per
|
|
444
|
+
measurement; source names the channel read
|
|
445
|
+
(`log` for the -o file, `stdio_fallback` else).
|
|
446
|
+
|
|
447
|
+
Raises:
|
|
448
|
+
AdapterError: runner_unavailable when ngspice produced
|
|
449
|
+
no output at all.
|
|
450
|
+
"""
|
|
451
|
+
sources = native_output.get("sources") or []
|
|
452
|
+
first_source = sources[0] if sources else ""
|
|
453
|
+
# MIGRATE-STDIO-FILE tier 1: the `-o ngspice.log` file is the
|
|
454
|
+
# parse channel; stdio survives only as a loud fallback for
|
|
455
|
+
# natives recorded before the file channel existed.
|
|
456
|
+
log = native_output.get("log") or ""
|
|
457
|
+
if log.strip():
|
|
458
|
+
combined, source = log, "log"
|
|
459
|
+
else:
|
|
460
|
+
stdio = "\n".join(
|
|
461
|
+
[native_output.get("stdout") or "",
|
|
462
|
+
native_output.get("stderr") or ""]
|
|
463
|
+
)
|
|
464
|
+
if not stdio.strip():
|
|
465
|
+
raise AdapterError(
|
|
466
|
+
"runner_unavailable", "ngspice produced no output at all."
|
|
467
|
+
)
|
|
468
|
+
combined, source = stdio, "stdio_fallback"
|
|
469
|
+
values = {}
|
|
470
|
+
for match in _MEAS_RE.finditer(combined):
|
|
471
|
+
try:
|
|
472
|
+
values[match.group(1)] = float(match.group(2))
|
|
473
|
+
except ValueError:
|
|
474
|
+
continue
|
|
475
|
+
expected = native_output.get("measurements") or []
|
|
476
|
+
missing = [item["name"] for item in expected if item["name"] not in values]
|
|
477
|
+
if missing:
|
|
478
|
+
errors = [
|
|
479
|
+
line.strip()
|
|
480
|
+
for line in combined.splitlines()
|
|
481
|
+
if _ERROR_RE.search(line)
|
|
482
|
+
]
|
|
483
|
+
detail = (
|
|
484
|
+
f" ngspice said: {errors[0]}"
|
|
485
|
+
if errors
|
|
486
|
+
else " (no meas lines and no ngspice error text)"
|
|
487
|
+
)
|
|
488
|
+
violations = [
|
|
489
|
+
{
|
|
490
|
+
"rule": "sim_fail",
|
|
491
|
+
"severity": "error",
|
|
492
|
+
"message": (
|
|
493
|
+
f"ngspice produced no value for {name}.{detail}"
|
|
494
|
+
),
|
|
495
|
+
"file": first_source,
|
|
496
|
+
}
|
|
497
|
+
for name in missing
|
|
498
|
+
]
|
|
499
|
+
return {
|
|
500
|
+
"total": len(violations),
|
|
501
|
+
"violations": violations,
|
|
502
|
+
"source": source,
|
|
503
|
+
}
|
|
504
|
+
violations = []
|
|
505
|
+
measurements = []
|
|
506
|
+
for item in expected:
|
|
507
|
+
actual = values[item["name"]]
|
|
508
|
+
bounds = {
|
|
509
|
+
key: item[key]
|
|
510
|
+
for key in ("min", "max")
|
|
511
|
+
if item.get(key) is not None
|
|
512
|
+
}
|
|
513
|
+
margins = {}
|
|
514
|
+
if "min" in bounds:
|
|
515
|
+
margins["min"] = actual - bounds["min"]
|
|
516
|
+
if "max" in bounds:
|
|
517
|
+
margins["max"] = bounds["max"] - actual
|
|
518
|
+
failed = [
|
|
519
|
+
f"{kind} {bounds[kind]}"
|
|
520
|
+
for kind, margin in margins.items()
|
|
521
|
+
if margin < 0
|
|
522
|
+
]
|
|
523
|
+
status = "fail" if failed else "pass"
|
|
524
|
+
margin = min(margins.values())
|
|
525
|
+
unit = item.get("unit") or ""
|
|
526
|
+
measurements.append(
|
|
527
|
+
{
|
|
528
|
+
"name": item["name"],
|
|
529
|
+
"value": actual,
|
|
530
|
+
"unit": unit,
|
|
531
|
+
"target": dict(bounds),
|
|
532
|
+
"margin": margin,
|
|
533
|
+
"status": status,
|
|
534
|
+
}
|
|
535
|
+
)
|
|
536
|
+
if failed:
|
|
537
|
+
unit_suffix = f" {unit}" if unit else ""
|
|
538
|
+
violations.append(
|
|
539
|
+
{
|
|
540
|
+
"rule": "measurement",
|
|
541
|
+
"severity": "error",
|
|
542
|
+
"message": (
|
|
543
|
+
f"{item['name']} = {actual:.6g}{unit_suffix} "
|
|
544
|
+
f"misses {', '.join(failed)}{unit_suffix} "
|
|
545
|
+
f"(margin {margin:.6g}{unit_suffix})."
|
|
546
|
+
),
|
|
547
|
+
"file": first_source,
|
|
548
|
+
}
|
|
549
|
+
)
|
|
550
|
+
return {
|
|
551
|
+
"total": len(violations),
|
|
552
|
+
"violations": violations,
|
|
553
|
+
"measurements": measurements,
|
|
554
|
+
"source": source,
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
@staticmethod
|
|
558
|
+
def _resolve_model(pdk):
|
|
559
|
+
if pdk is None:
|
|
560
|
+
return None
|
|
561
|
+
if not isinstance(pdk, dict):
|
|
562
|
+
raise AdapterError(
|
|
563
|
+
"invalid_command", f"PDK pin must be a mapping: {pdk!r}."
|
|
564
|
+
)
|
|
565
|
+
pdk_id = pdk.get("id")
|
|
566
|
+
revision = pdk.get("revision")
|
|
567
|
+
if not pdk_id or not revision:
|
|
568
|
+
raise AdapterError(
|
|
569
|
+
"invalid_command",
|
|
570
|
+
f"PDK pin needs id and revision: {pdk!r}.",
|
|
571
|
+
)
|
|
572
|
+
rel = pdk.get("model_rel") or default_model_rel(pdk.get("id"))
|
|
573
|
+
if rel is None:
|
|
574
|
+
raise AdapterError(
|
|
575
|
+
"invalid_command",
|
|
576
|
+
f"PDK {pdk.get('id')!r} declares no sim profile: pass an "
|
|
577
|
+
"explicit model_rel (see docs/pdk/<lane>/profile.yaml).",
|
|
578
|
+
)
|
|
579
|
+
try:
|
|
580
|
+
hit = resolve_deck(
|
|
581
|
+
pdk_id, revision, rel, engine_root=pdk.get("engine_root")
|
|
582
|
+
)
|
|
583
|
+
except Exception as exc:
|
|
584
|
+
# PdkError codes (unknown_pdk, pdk_drift, ...) ride
|
|
585
|
+
# through unchanged so a model mismatch fails loudly
|
|
586
|
+
# with its store meaning, never as a generic error.
|
|
587
|
+
raise AdapterError(
|
|
588
|
+
getattr(exc, "code", "unknown_pdk"), str(exc)
|
|
589
|
+
) from exc
|
|
590
|
+
return hit["path"]
|
|
591
|
+
|
|
592
|
+
@staticmethod
|
|
593
|
+
def _resolve(job):
|
|
594
|
+
tool_dir = job.get("tool_dir")
|
|
595
|
+
if tool_dir:
|
|
596
|
+
candidate = Path(tool_dir) / "ngspice"
|
|
597
|
+
if candidate.is_file():
|
|
598
|
+
return str(candidate)
|
|
599
|
+
raise AdapterError(
|
|
600
|
+
"missing_dependency", f"ngspice not found in {tool_dir}."
|
|
601
|
+
)
|
|
602
|
+
found = shutil.which("ngspice")
|
|
603
|
+
if found is None:
|
|
604
|
+
raise AdapterError(
|
|
605
|
+
"missing_dependency",
|
|
606
|
+
"ngspice not on PATH (pass tool_dir or integrate via B10).",
|
|
607
|
+
)
|
|
608
|
+
return found
|
|
609
|
+
|
|
610
|
+
def _run(self, argv, workdir, timeout_s, extra_binds=()):
|
|
611
|
+
if self._runner is not None:
|
|
612
|
+
try:
|
|
613
|
+
return self._runner.run(argv, workdir, timeout_s, extra_binds)
|
|
614
|
+
except ExecutionError as exc:
|
|
615
|
+
raise AdapterError(exc.code, str(exc)) from exc
|
|
616
|
+
try:
|
|
617
|
+
return subprocess.run(
|
|
618
|
+
argv,
|
|
619
|
+
capture_output=True,
|
|
620
|
+
text=True,
|
|
621
|
+
timeout=timeout_s,
|
|
622
|
+
cwd=workdir,
|
|
623
|
+
)
|
|
624
|
+
except subprocess.TimeoutExpired as exc:
|
|
625
|
+
raise AdapterError(
|
|
626
|
+
"runner_unavailable", f"spice sim timed out after {timeout_s}s."
|
|
627
|
+
) from exc
|