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,1062 @@
|
|
|
1
|
+
"""Post-layout SPICE adapter: run_post_layout behind the ToolAdapter port.
|
|
2
|
+
|
|
3
|
+
Protocol, mirroring the run_spice adapter: stage a corner deck into the
|
|
4
|
+
workdir — preamble (.param/.option/.temp/.lib) + extracted netlist plus
|
|
5
|
+
testbench bodies (bare `.end` lines stripped, else ngspice stops before
|
|
6
|
+
.control) + generated .control (save all, analyses, let/print meas_*,
|
|
7
|
+
optional wrdata) — plus a .spiceinit (ngbehavior=hsa, required to read
|
|
8
|
+
sky130 libs), then run `ngspice -b -o postlayout.log postlayout_deck.spice`
|
|
9
|
+
under a timeout and parse `meas_* = value` lines. Verdicts always come
|
|
10
|
+
from parsed output, never the exit code: bound misses are FAIL, a sim
|
|
11
|
+
that yields no values synthesizes sim_fail violations, and only a
|
|
12
|
+
missing binary raises missing_dependency.
|
|
13
|
+
|
|
14
|
+
Two post-layout additions ride on top. Provenance is enforced: the job
|
|
15
|
+
carries the extraction record (extraction_run_id plus the netlist
|
|
16
|
+
sha256), and a missing record or bytes that no longer match the record
|
|
17
|
+
refuse loudly — a schematic passed as extracted without a fresh record
|
|
18
|
+
never sims. And every parsed measurement joins a pre/post comparison
|
|
19
|
+
row against the schematic run referenced by schematic_run_id (run_id
|
|
20
|
+
accepted as an alias), whose values arrive either explicitly in the job
|
|
21
|
+
or through the runs resolver; deltas are evidence only and never gate
|
|
22
|
+
the verdict.
|
|
23
|
+
|
|
24
|
+
PDK models resolve through the PDK store (resolve_deck): a wrong
|
|
25
|
+
revision, an unknown model relpath, or a tampered model file fails
|
|
26
|
+
loudly in preflight — the adapter never silently sims against
|
|
27
|
+
substitute models.
|
|
28
|
+
|
|
29
|
+
Tool resolution: an explicit job `tool_dir` containing ngspice wins,
|
|
30
|
+
else PATH. Where the tools live on disk is B10 scope.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
import hashlib
|
|
34
|
+
import math
|
|
35
|
+
import re
|
|
36
|
+
import shutil
|
|
37
|
+
import subprocess
|
|
38
|
+
from pathlib import Path
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
from ...execution.errors import ExecutionError
|
|
42
|
+
except ImportError:
|
|
43
|
+
from rocket_engine.execution.errors import ExecutionError
|
|
44
|
+
try:
|
|
45
|
+
from ...execution.runner import tool_roots
|
|
46
|
+
except ImportError:
|
|
47
|
+
from rocket_engine.execution.runner import tool_roots
|
|
48
|
+
try:
|
|
49
|
+
from ...interfaces import ToolAdapter
|
|
50
|
+
except ImportError:
|
|
51
|
+
from rocket_engine.interfaces import ToolAdapter
|
|
52
|
+
try:
|
|
53
|
+
from ...interfaces.pdks import resolve_deck
|
|
54
|
+
except ImportError:
|
|
55
|
+
from rocket_engine.interfaces.pdks import resolve_deck
|
|
56
|
+
try:
|
|
57
|
+
from ..errors import AdapterError
|
|
58
|
+
except ImportError:
|
|
59
|
+
from rocket_engine.adapters.errors import AdapterError
|
|
60
|
+
|
|
61
|
+
OPERATION = "run_post_layout"
|
|
62
|
+
_TIMEOUT_S = 120
|
|
63
|
+
_DECK_NAME = "postlayout_deck.spice"
|
|
64
|
+
_LOG_NAME = "postlayout.log"
|
|
65
|
+
_SPICEINIT_NAME = ".spiceinit"
|
|
66
|
+
_WAVEFORMS_NAME = "postlayout_waveforms.dat"
|
|
67
|
+
_MODEL_REL = "libs.tech/combined/sky130.lib.spice"
|
|
68
|
+
_WAVEFORM_MAX_ROWS = 100000
|
|
69
|
+
|
|
70
|
+
# Mirrors the run_spice adapter MEAS_RE (leading whitespace tolerated).
|
|
71
|
+
_MEAS_RE = re.compile(
|
|
72
|
+
r"^\s*meas_([A-Za-z0-9_]+)\s*=\s*"
|
|
73
|
+
r"(-?[0-9]*\.?[0-9]+(?:[eE][-+]?[0-9]+)?)",
|
|
74
|
+
re.M,
|
|
75
|
+
)
|
|
76
|
+
_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
77
|
+
_CORNER_RE = re.compile(r"^[A-Za-z0-9_]+$")
|
|
78
|
+
_PARAM_KEY_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
79
|
+
_SHA_RE = re.compile(r"^[0-9a-fA-F]{64}$")
|
|
80
|
+
_ERROR_RE = re.compile(r"error", re.IGNORECASE)
|
|
81
|
+
|
|
82
|
+
_SPICEINIT = """* rocket_engine ngspice init (mirrors sky130-bandgap sim/spiceinit).
|
|
83
|
+
set ngbehavior=hsa ; HSPICE-ish compatibility, required to read PDK libs
|
|
84
|
+
set skywaterpdk ; skip some checks for faster lib loading
|
|
85
|
+
set ng_nomodcheck ; do not check model parameters
|
|
86
|
+
option noinit ; do not dump operating point data into the log
|
|
87
|
+
option klu ; KLU direct linear solver
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _is_number(value):
|
|
92
|
+
return (
|
|
93
|
+
isinstance(value, (int, float))
|
|
94
|
+
and not isinstance(value, bool)
|
|
95
|
+
and math.isfinite(value)
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _single_line(value):
|
|
100
|
+
return (
|
|
101
|
+
isinstance(value, str)
|
|
102
|
+
and value.strip()
|
|
103
|
+
and "\n" not in value
|
|
104
|
+
and "\r" not in value
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _check_measurements(measurements):
|
|
109
|
+
if not isinstance(measurements, list) or not measurements:
|
|
110
|
+
raise AdapterError(
|
|
111
|
+
"invalid_command", "run_post_layout needs at least one measurement."
|
|
112
|
+
)
|
|
113
|
+
clean = []
|
|
114
|
+
for item in measurements:
|
|
115
|
+
if not isinstance(item, dict):
|
|
116
|
+
raise AdapterError(
|
|
117
|
+
"invalid_command", f"Bad measurement {item!r} (not a mapping)."
|
|
118
|
+
)
|
|
119
|
+
name = item.get("name")
|
|
120
|
+
expr = item.get("expr")
|
|
121
|
+
if not isinstance(name, str) or not _NAME_RE.match(name):
|
|
122
|
+
raise AdapterError(
|
|
123
|
+
"invalid_command", f"Bad measurement name: {name!r}."
|
|
124
|
+
)
|
|
125
|
+
if not _single_line(expr):
|
|
126
|
+
raise AdapterError(
|
|
127
|
+
"invalid_command",
|
|
128
|
+
f"Measurement {name!r} needs a one-line expr.",
|
|
129
|
+
)
|
|
130
|
+
bounds = {
|
|
131
|
+
key: item[key]
|
|
132
|
+
for key in ("min", "max")
|
|
133
|
+
if item.get(key) is not None
|
|
134
|
+
}
|
|
135
|
+
if not bounds or not all(_is_number(v) for v in bounds.values()):
|
|
136
|
+
raise AdapterError(
|
|
137
|
+
"invalid_command",
|
|
138
|
+
f"Measurement {name!r} needs numeric min and/or max.",
|
|
139
|
+
)
|
|
140
|
+
if (
|
|
141
|
+
"min" in bounds
|
|
142
|
+
and "max" in bounds
|
|
143
|
+
and bounds["min"] > bounds["max"]
|
|
144
|
+
):
|
|
145
|
+
raise AdapterError(
|
|
146
|
+
"invalid_command",
|
|
147
|
+
f"Measurement {name!r} has min above max.",
|
|
148
|
+
)
|
|
149
|
+
clean.append(
|
|
150
|
+
{
|
|
151
|
+
"name": name,
|
|
152
|
+
"expr": expr.strip(),
|
|
153
|
+
"unit": item.get("unit", ""),
|
|
154
|
+
"min": bounds.get("min"),
|
|
155
|
+
"max": bounds.get("max"),
|
|
156
|
+
}
|
|
157
|
+
)
|
|
158
|
+
return clean
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _check_waveforms(waveforms):
|
|
162
|
+
if waveforms is None:
|
|
163
|
+
return []
|
|
164
|
+
if not isinstance(waveforms, list):
|
|
165
|
+
raise AdapterError(
|
|
166
|
+
"invalid_command", f"Waveforms must be a list: {waveforms!r}."
|
|
167
|
+
)
|
|
168
|
+
clean = []
|
|
169
|
+
for item in waveforms:
|
|
170
|
+
if not isinstance(item, dict):
|
|
171
|
+
raise AdapterError(
|
|
172
|
+
"invalid_command", f"Bad waveform {item!r} (not a mapping)."
|
|
173
|
+
)
|
|
174
|
+
name = item.get("name")
|
|
175
|
+
expr = item.get("expr")
|
|
176
|
+
if not isinstance(name, str) or not _NAME_RE.match(name):
|
|
177
|
+
raise AdapterError(
|
|
178
|
+
"invalid_command", f"Bad waveform name: {name!r}."
|
|
179
|
+
)
|
|
180
|
+
if not _single_line(expr) or expr.strip().startswith("."):
|
|
181
|
+
raise AdapterError(
|
|
182
|
+
"invalid_command",
|
|
183
|
+
f"Waveform {name!r} needs a one-line vector expr.",
|
|
184
|
+
)
|
|
185
|
+
clean.append({"name": name, "expr": expr.strip()})
|
|
186
|
+
return clean
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _check_control_lines(values, kind):
|
|
190
|
+
clean = []
|
|
191
|
+
for value in values or []:
|
|
192
|
+
if not _single_line(value) or value.strip().startswith("."):
|
|
193
|
+
raise AdapterError(
|
|
194
|
+
"invalid_command", f"Bad {kind} line: {value!r}."
|
|
195
|
+
)
|
|
196
|
+
clean.append(value.strip())
|
|
197
|
+
return clean
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _check_params(params):
|
|
201
|
+
clean = {}
|
|
202
|
+
for key, value in (params or {}).items():
|
|
203
|
+
if not isinstance(key, str) or not _PARAM_KEY_RE.match(key):
|
|
204
|
+
raise AdapterError("invalid_command", f"Bad .param name: {key!r}.")
|
|
205
|
+
if isinstance(value, bool) or not isinstance(value, (int, float, str)):
|
|
206
|
+
raise AdapterError(
|
|
207
|
+
"invalid_command", f"Bad .param value for {key!r}: {value!r}."
|
|
208
|
+
)
|
|
209
|
+
text = str(value)
|
|
210
|
+
if not text or any(char.isspace() for char in text) or '"' in text:
|
|
211
|
+
raise AdapterError(
|
|
212
|
+
"invalid_command", f"Bad .param value for {key!r}: {value!r}."
|
|
213
|
+
)
|
|
214
|
+
clean[key] = text
|
|
215
|
+
return clean
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _strip_end_lines(text):
|
|
219
|
+
return "\n".join(
|
|
220
|
+
line for line in text.splitlines() if line.strip().lower() != ".end"
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _read_source(path):
|
|
225
|
+
try:
|
|
226
|
+
return Path(path).read_text(encoding="utf-8")
|
|
227
|
+
except OSError as exc:
|
|
228
|
+
raise AdapterError(
|
|
229
|
+
"invalid_reference", f"Netlist unreadable: {path} ({exc})."
|
|
230
|
+
) from exc
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _sha256_file(path):
|
|
234
|
+
return hashlib.sha256(Path(path).read_bytes()).hexdigest()
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _check_provenance(record, extracted):
|
|
238
|
+
if not isinstance(record, dict):
|
|
239
|
+
raise AdapterError(
|
|
240
|
+
"invalid_command",
|
|
241
|
+
"run_post_layout needs extraction_provenance "
|
|
242
|
+
"{extraction_run_id, netlist_sha256, ...}; refusing to sim "
|
|
243
|
+
"a schematic as extracted.",
|
|
244
|
+
)
|
|
245
|
+
run_id = record.get("extraction_run_id")
|
|
246
|
+
if not _single_line(run_id):
|
|
247
|
+
raise AdapterError(
|
|
248
|
+
"invalid_command",
|
|
249
|
+
"extraction_provenance needs a one-line extraction_run_id.",
|
|
250
|
+
)
|
|
251
|
+
digest = record.get("netlist_sha256")
|
|
252
|
+
if not isinstance(digest, str) or _SHA_RE.match(digest) is None:
|
|
253
|
+
raise AdapterError(
|
|
254
|
+
"invalid_command",
|
|
255
|
+
"extraction_provenance needs netlist_sha256 as 64 hex chars.",
|
|
256
|
+
)
|
|
257
|
+
if _sha256_file(extracted).lower() != digest.lower():
|
|
258
|
+
raise AdapterError(
|
|
259
|
+
"invalid_reference",
|
|
260
|
+
f"Stale extraction record for {extracted}: netlist bytes differ "
|
|
261
|
+
f"from extraction {run_id} (re-extract, then rerun).",
|
|
262
|
+
)
|
|
263
|
+
spice_file = record.get("spice_file")
|
|
264
|
+
if spice_file is not None:
|
|
265
|
+
if Path(spice_file).name != Path(extracted).name:
|
|
266
|
+
raise AdapterError(
|
|
267
|
+
"invalid_reference",
|
|
268
|
+
f"extraction_provenance names {spice_file}, got {extracted}.",
|
|
269
|
+
)
|
|
270
|
+
clean = {
|
|
271
|
+
"extraction_run_id": run_id.strip(),
|
|
272
|
+
"netlist_sha256": digest.lower(),
|
|
273
|
+
}
|
|
274
|
+
for key in ("cell", "tech"):
|
|
275
|
+
if record.get(key) is not None:
|
|
276
|
+
if not _single_line(record[key]):
|
|
277
|
+
raise AdapterError(
|
|
278
|
+
"invalid_command",
|
|
279
|
+
f"extraction_provenance needs a one-line {key}.",
|
|
280
|
+
)
|
|
281
|
+
clean[key] = record[key].strip()
|
|
282
|
+
if spice_file is not None:
|
|
283
|
+
clean["spice_file"] = str(spice_file)
|
|
284
|
+
if record.get("device_count") is not None:
|
|
285
|
+
count = record["device_count"]
|
|
286
|
+
if (
|
|
287
|
+
not isinstance(count, int)
|
|
288
|
+
or isinstance(count, bool)
|
|
289
|
+
or count < 0
|
|
290
|
+
):
|
|
291
|
+
raise AdapterError(
|
|
292
|
+
"invalid_command",
|
|
293
|
+
f"Bad device_count: {count!r} (want an int >= 0).",
|
|
294
|
+
)
|
|
295
|
+
clean["device_count"] = count
|
|
296
|
+
return clean
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _clean_value_list(items, what, code):
|
|
300
|
+
if not isinstance(items, list) or not items:
|
|
301
|
+
raise AdapterError(code, f"{what} has no measurements.")
|
|
302
|
+
values = {}
|
|
303
|
+
for item in items:
|
|
304
|
+
if not isinstance(item, dict):
|
|
305
|
+
raise AdapterError(code, f"{what} entry is not a mapping: {item!r}.")
|
|
306
|
+
name = item.get("name")
|
|
307
|
+
value = item.get("value")
|
|
308
|
+
if not isinstance(name, str) or not name:
|
|
309
|
+
raise AdapterError(code, f"{what} entry needs a name: {item!r}.")
|
|
310
|
+
if not _is_number(value):
|
|
311
|
+
raise AdapterError(
|
|
312
|
+
code, f"{what} entry {name!r} needs a numeric value."
|
|
313
|
+
)
|
|
314
|
+
values[name] = {"value": float(value), "unit": item.get("unit", "")}
|
|
315
|
+
return values
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _normalize_runs_result(raw, run_id):
|
|
319
|
+
if isinstance(raw, list):
|
|
320
|
+
items = raw
|
|
321
|
+
elif isinstance(raw, dict) and isinstance(raw.get("measurements"), list):
|
|
322
|
+
items = raw["measurements"]
|
|
323
|
+
elif isinstance(raw, dict):
|
|
324
|
+
items = [
|
|
325
|
+
{
|
|
326
|
+
"name": name,
|
|
327
|
+
"value": value.get("value") if isinstance(value, dict) else value,
|
|
328
|
+
"unit": value.get("unit", "") if isinstance(value, dict) else "",
|
|
329
|
+
}
|
|
330
|
+
for name, value in raw.items()
|
|
331
|
+
]
|
|
332
|
+
else:
|
|
333
|
+
raise AdapterError(
|
|
334
|
+
"invalid_reference",
|
|
335
|
+
f"Unknown schematic run: {run_id} (no measurements).",
|
|
336
|
+
)
|
|
337
|
+
return _clean_value_list(items, f"Schematic run {run_id}", "invalid_reference")
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _parse_waveforms_file(path, spec):
|
|
341
|
+
try:
|
|
342
|
+
text = Path(path).read_text(encoding="utf-8")
|
|
343
|
+
except OSError:
|
|
344
|
+
return []
|
|
345
|
+
columns = [[] for _ in range(len(spec) + 1)]
|
|
346
|
+
for line in text.splitlines():
|
|
347
|
+
parts = line.split()
|
|
348
|
+
if not parts:
|
|
349
|
+
continue
|
|
350
|
+
try:
|
|
351
|
+
numbers = [float(part) for part in parts]
|
|
352
|
+
except ValueError:
|
|
353
|
+
continue
|
|
354
|
+
if len(numbers) != len(spec) + 1:
|
|
355
|
+
continue
|
|
356
|
+
for index, number in enumerate(numbers):
|
|
357
|
+
columns[index].append(number)
|
|
358
|
+
if not columns[0]:
|
|
359
|
+
return []
|
|
360
|
+
downsampled = False
|
|
361
|
+
if len(columns[0]) > _WAVEFORM_MAX_ROWS:
|
|
362
|
+
step = -(-len(columns[0]) // _WAVEFORM_MAX_ROWS)
|
|
363
|
+
columns = [column[::step] for column in columns]
|
|
364
|
+
downsampled = True
|
|
365
|
+
scale, rest = columns[0], columns[1:]
|
|
366
|
+
return [
|
|
367
|
+
{
|
|
368
|
+
"name": item["name"],
|
|
369
|
+
"expr": item["expr"],
|
|
370
|
+
"samples": samples,
|
|
371
|
+
"scale": list(scale),
|
|
372
|
+
"count": len(scale),
|
|
373
|
+
"downsampled": downsampled,
|
|
374
|
+
}
|
|
375
|
+
for item, samples in zip(spec, rest)
|
|
376
|
+
]
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _format_cell(value):
|
|
380
|
+
if value is None:
|
|
381
|
+
return "-"
|
|
382
|
+
if isinstance(value, bool):
|
|
383
|
+
return str(value)
|
|
384
|
+
if isinstance(value, (int, float)):
|
|
385
|
+
return f"{value:.6g}"
|
|
386
|
+
return str(value)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def build_provenance(spice_file, extraction_run_id, cell=None, tech=None,
|
|
390
|
+
device_count=None):
|
|
391
|
+
"""Build an extraction record hashing the netlist on disk.
|
|
392
|
+
|
|
393
|
+
The extraction side calls this when the .spice artifact lands, so
|
|
394
|
+
the post-layout side can verify freshness instead of trusting a
|
|
395
|
+
hand-written hash.
|
|
396
|
+
|
|
397
|
+
Args:
|
|
398
|
+
spice_file: Extracted netlist path the record pins.
|
|
399
|
+
extraction_run_id: run_extraction run id that produced it.
|
|
400
|
+
cell: Extracted cell name, when known.
|
|
401
|
+
tech: Extraction tech name, when known.
|
|
402
|
+
device_count: Extracted device count, when known.
|
|
403
|
+
|
|
404
|
+
Returns:
|
|
405
|
+
Provenance record with extraction_run_id, netlist_sha256,
|
|
406
|
+
spice_file, and the known extras.
|
|
407
|
+
|
|
408
|
+
Raises:
|
|
409
|
+
AdapterError: invalid_reference for a missing netlist,
|
|
410
|
+
invalid_command for a bad run id, cell, tech, or count.
|
|
411
|
+
"""
|
|
412
|
+
if not _single_line(extraction_run_id):
|
|
413
|
+
raise AdapterError(
|
|
414
|
+
"invalid_command",
|
|
415
|
+
"build_provenance needs a one-line extraction_run_id.",
|
|
416
|
+
)
|
|
417
|
+
if not Path(spice_file).is_file():
|
|
418
|
+
raise AdapterError(
|
|
419
|
+
"invalid_reference", f"Extracted netlist not found: {spice_file}."
|
|
420
|
+
)
|
|
421
|
+
record = {
|
|
422
|
+
"extraction_run_id": extraction_run_id.strip(),
|
|
423
|
+
"netlist_sha256": _sha256_file(spice_file),
|
|
424
|
+
"spice_file": str(spice_file),
|
|
425
|
+
}
|
|
426
|
+
for key, value in (("cell", cell), ("tech", tech)):
|
|
427
|
+
if value is not None:
|
|
428
|
+
if not _single_line(value):
|
|
429
|
+
raise AdapterError(
|
|
430
|
+
"invalid_command",
|
|
431
|
+
f"build_provenance needs a one-line {key}.",
|
|
432
|
+
)
|
|
433
|
+
record[key] = value.strip()
|
|
434
|
+
if device_count is not None:
|
|
435
|
+
if (
|
|
436
|
+
not isinstance(device_count, int)
|
|
437
|
+
or isinstance(device_count, bool)
|
|
438
|
+
or device_count < 0
|
|
439
|
+
):
|
|
440
|
+
raise AdapterError(
|
|
441
|
+
"invalid_command",
|
|
442
|
+
f"Bad device_count: {device_count!r} (want an int >= 0).",
|
|
443
|
+
)
|
|
444
|
+
record["device_count"] = device_count
|
|
445
|
+
return record
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def build_deck(title, corner, temp_c, params, options, model_lib, bodies,
|
|
449
|
+
analyses, measurements, waveforms):
|
|
450
|
+
"""Render the post-layout deck (preamble + bodies + .control).
|
|
451
|
+
|
|
452
|
+
Args:
|
|
453
|
+
title: Deck title line (without the `*`).
|
|
454
|
+
corner: Model corner named on the .lib line.
|
|
455
|
+
temp_c: Temperature in C for the .temp line.
|
|
456
|
+
params: Cleaned .param name/value mapping.
|
|
457
|
+
options: Cleaned .option lines.
|
|
458
|
+
model_lib: Store model library path, or None for model-free.
|
|
459
|
+
bodies: Netlist body texts (.end lines already stripped).
|
|
460
|
+
analyses: .control analysis lines (op when empty).
|
|
461
|
+
measurements: Cleaned measurement items (let/print pairs).
|
|
462
|
+
waveforms: Cleaned waveform items (one wrdata line).
|
|
463
|
+
|
|
464
|
+
Returns:
|
|
465
|
+
Rendered post-layout deck text.
|
|
466
|
+
"""
|
|
467
|
+
head = [
|
|
468
|
+
f"* {title}",
|
|
469
|
+
f"* corner: {corner} temp: {temp_c}C "
|
|
470
|
+
"(generated by rocket_engine postlayout adapter)",
|
|
471
|
+
]
|
|
472
|
+
for key, value in params.items():
|
|
473
|
+
head.append(f".param {key}={value}")
|
|
474
|
+
for opt in options:
|
|
475
|
+
head.append(f".option {opt}")
|
|
476
|
+
head.append(f".temp {temp_c}")
|
|
477
|
+
if model_lib is not None:
|
|
478
|
+
head.append(f'.lib "{model_lib}" {corner}')
|
|
479
|
+
control = [".control", "save all"]
|
|
480
|
+
control.extend(analyses or ["op"])
|
|
481
|
+
for item in measurements:
|
|
482
|
+
control.append(f"let meas_{item['name']} = {item['expr']}")
|
|
483
|
+
for item in measurements:
|
|
484
|
+
control.append(f"print meas_{item['name']}")
|
|
485
|
+
if waveforms:
|
|
486
|
+
exprs = " ".join(item["expr"] for item in waveforms)
|
|
487
|
+
control.append(f"wrdata {_WAVEFORMS_NAME} {exprs}")
|
|
488
|
+
control.extend(["quit", ".endc", ".end", ""])
|
|
489
|
+
return "\n".join(head + bodies + control)
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def delta_table(comparison):
|
|
493
|
+
"""Render pre/post comparison rows as an ASCII delta table.
|
|
494
|
+
|
|
495
|
+
Args:
|
|
496
|
+
comparison: Comparison rows with name, pre, post, delta,
|
|
497
|
+
rel, and status keys.
|
|
498
|
+
|
|
499
|
+
Returns:
|
|
500
|
+
Table text with one header line plus one line per row.
|
|
501
|
+
"""
|
|
502
|
+
header = (
|
|
503
|
+
f"{'name':<14} {'pre':>12} {'post':>12} "
|
|
504
|
+
f"{'delta':>12} {'rel':>12} status"
|
|
505
|
+
)
|
|
506
|
+
if not comparison:
|
|
507
|
+
return header + "\n(no comparison rows)"
|
|
508
|
+
lines = [header]
|
|
509
|
+
for row in comparison:
|
|
510
|
+
lines.append(
|
|
511
|
+
f"{row.get('name', '?'):<14} "
|
|
512
|
+
f"{_format_cell(row.get('pre')):>12} "
|
|
513
|
+
f"{_format_cell(row.get('post')):>12} "
|
|
514
|
+
f"{_format_cell(row.get('delta')):>12} "
|
|
515
|
+
f"{_format_cell(row.get('rel')):>12} "
|
|
516
|
+
f"{row.get('status', '?')}"
|
|
517
|
+
)
|
|
518
|
+
return "\n".join(lines)
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
class PostLayoutAdapter(ToolAdapter):
|
|
522
|
+
"""run_post_layout via ngspice: extracted sims with pre/post deltas.
|
|
523
|
+
|
|
524
|
+
Serves the run_post_layout operation with deck.tool `ngspice`
|
|
525
|
+
(the only tool). Sims the extracted netlist plus testbench
|
|
526
|
+
through ngspice in batch, evaluates post-layout measurements
|
|
527
|
+
against min/max bounds, dumps requested waveforms via wrdata,
|
|
528
|
+
and joins every measurement with its schematic value into a
|
|
529
|
+
pre/post comparison table; deltas are evidence only and never
|
|
530
|
+
gate the verdict.
|
|
531
|
+
|
|
532
|
+
Job keys:
|
|
533
|
+
extracted_netlist (required): Extracted netlist path simmed
|
|
534
|
+
as the first body (never a schematic without a record).
|
|
535
|
+
extraction_provenance (required): {extraction_run_id,
|
|
536
|
+
netlist_sha256, cell?, tech?, spice_file?, device_count?}
|
|
537
|
+
pinning the extraction; stale records refuse loudly.
|
|
538
|
+
measurements (required): [{name, expr, unit?, min?, max?}]
|
|
539
|
+
with numeric min and/or max each (bounds judge POST).
|
|
540
|
+
schematic_run_id (required): Schematic run_spice run id the
|
|
541
|
+
comparison reads pre values for (run_id is an alias).
|
|
542
|
+
schematic_measurements: Explicit pre values [{name, value,
|
|
543
|
+
unit?}] winning over the runs resolver.
|
|
544
|
+
testbench: Testbench wrapper path staged after the netlist.
|
|
545
|
+
waveforms: [{name, expr}] vector exprs dumped via wrdata.
|
|
546
|
+
analyses: .control analysis lines (default op).
|
|
547
|
+
options: .option lines (default [wnflag=1]).
|
|
548
|
+
params: .param name/value mapping.
|
|
549
|
+
corner: Model corner (default tt).
|
|
550
|
+
temp_c: Temperature in C (default 27).
|
|
551
|
+
title: Deck title line.
|
|
552
|
+
pdk: {id, revision, model_rel?, engine_root?} pin for PDK
|
|
553
|
+
models via the store; omitted sims model-free.
|
|
554
|
+
timeout_s: Sim budget in seconds (default 120).
|
|
555
|
+
tool_dir: Directory holding ngspice; PATH otherwise.
|
|
556
|
+
deck: Optional mapping; deck.tool must be ngspice here.
|
|
557
|
+
workdir: Run directory (default ".").
|
|
558
|
+
|
|
559
|
+
Args:
|
|
560
|
+
runner: Optional isolated runner; direct subprocess when None.
|
|
561
|
+
runs: Optional schematic resolver run_id -> measurements
|
|
562
|
+
(list, {measurements: [...]}, or {name: value}); used
|
|
563
|
+
only when the job carries no schematic_measurements.
|
|
564
|
+
|
|
565
|
+
Caution: Verdicts always come from parsed output, never the exit
|
|
566
|
+
code — a missing or stale extraction record refuses instead
|
|
567
|
+
of simming a schematic as extracted, and PDK models resolve
|
|
568
|
+
through the store, so a wrong revision or tampered model
|
|
569
|
+
fails loudly instead of simming against substitutes.
|
|
570
|
+
"""
|
|
571
|
+
|
|
572
|
+
def __init__(self, runner=None, runs=None):
|
|
573
|
+
self._runner = runner
|
|
574
|
+
self._runs = runs
|
|
575
|
+
|
|
576
|
+
def capabilities(self, target):
|
|
577
|
+
"""Report the served operation and tool name.
|
|
578
|
+
|
|
579
|
+
Args:
|
|
580
|
+
target: Target the capabilities are reported for
|
|
581
|
+
(accepted, unused — one netlist per job).
|
|
582
|
+
|
|
583
|
+
Returns:
|
|
584
|
+
Capability mapping with operations and tool.
|
|
585
|
+
"""
|
|
586
|
+
return {"operations": [OPERATION], "tool": "ngspice"}
|
|
587
|
+
|
|
588
|
+
def preflight(self, job):
|
|
589
|
+
"""Check operation, inputs, provenance, schematic ref, and binary.
|
|
590
|
+
|
|
591
|
+
Cheap deterministic checks first, provenance and schematic
|
|
592
|
+
resolution next (a stale record or unknown schematic run
|
|
593
|
+
must fail before any sim), PDK resolution after that, tool
|
|
594
|
+
resolution last.
|
|
595
|
+
|
|
596
|
+
Args:
|
|
597
|
+
job: Solver job with operation run_post_layout plus the
|
|
598
|
+
extracted netlist, provenance, measurements, and
|
|
599
|
+
schematic reference inputs.
|
|
600
|
+
|
|
601
|
+
Returns:
|
|
602
|
+
Checked mapping with the resolved ngspice path,
|
|
603
|
+
extracted netlist, testbench, measurements,
|
|
604
|
+
waveforms, analyses, options, params, corner,
|
|
605
|
+
temp_c, title, model_lib, schematic values,
|
|
606
|
+
provenance, and timeout_s.
|
|
607
|
+
|
|
608
|
+
Raises:
|
|
609
|
+
AdapterError: unsupported_operation for another
|
|
610
|
+
operation, invalid_command for bad inputs (sources
|
|
611
|
+
alias, timeout, measurements, waveforms, analyses,
|
|
612
|
+
options, params, corner, temp_c, title, missing or
|
|
613
|
+
malformed provenance, missing or conflicting
|
|
614
|
+
schematic ref, schematic gap), invalid_reference
|
|
615
|
+
for a missing netlist or testbench, a stale or
|
|
616
|
+
foreign provenance record, or an unresolvable
|
|
617
|
+
schematic run, store codes (unknown_pdk, pdk_drift,
|
|
618
|
+
...) for a bad PDK pin, missing_dependency for an
|
|
619
|
+
unresolvable ngspice.
|
|
620
|
+
"""
|
|
621
|
+
# Cheap deterministic checks first: operation, shape,
|
|
622
|
+
# timeout, inputs. Provenance and schematic resolution next
|
|
623
|
+
# (both must fail before any sim). PDK resolution after that
|
|
624
|
+
# (wrong rev must fail before any sim). Tool resolution last,
|
|
625
|
+
# since it depends on the machine.
|
|
626
|
+
if job.get("operation") != OPERATION:
|
|
627
|
+
raise AdapterError(
|
|
628
|
+
"unsupported_operation",
|
|
629
|
+
f"postlayout handles {OPERATION}, got {job.get('operation')}.",
|
|
630
|
+
)
|
|
631
|
+
if job.get("sources") is not None:
|
|
632
|
+
raise AdapterError(
|
|
633
|
+
"invalid_command",
|
|
634
|
+
"run_post_layout takes extracted_netlist, not sources.",
|
|
635
|
+
)
|
|
636
|
+
timeout = job.get("timeout_s", _TIMEOUT_S)
|
|
637
|
+
if (
|
|
638
|
+
not isinstance(timeout, (int, float))
|
|
639
|
+
or isinstance(timeout, bool)
|
|
640
|
+
or not timeout > 0
|
|
641
|
+
):
|
|
642
|
+
raise AdapterError("invalid_command", f"Bad timeout_s: {timeout!r}.")
|
|
643
|
+
extracted = job.get("extracted_netlist")
|
|
644
|
+
if not extracted or not Path(extracted).is_file():
|
|
645
|
+
raise AdapterError(
|
|
646
|
+
"invalid_reference", f"Extracted netlist not found: {extracted}."
|
|
647
|
+
)
|
|
648
|
+
testbench = job.get("testbench")
|
|
649
|
+
if testbench is not None and not Path(testbench).is_file():
|
|
650
|
+
raise AdapterError(
|
|
651
|
+
"invalid_reference", f"Testbench not found: {testbench}."
|
|
652
|
+
)
|
|
653
|
+
measurements = _check_measurements(job.get("measurements"))
|
|
654
|
+
waveforms = _check_waveforms(job.get("waveforms"))
|
|
655
|
+
analyses = _check_control_lines(job.get("analyses"), "analyses")
|
|
656
|
+
options = _check_control_lines(
|
|
657
|
+
job.get("options", ["wnflag=1"]), "options"
|
|
658
|
+
)
|
|
659
|
+
params = _check_params(job.get("params"))
|
|
660
|
+
corner = job.get("corner", "tt")
|
|
661
|
+
if not isinstance(corner, str) or not _CORNER_RE.match(corner):
|
|
662
|
+
raise AdapterError("invalid_command", f"Bad corner: {corner!r}.")
|
|
663
|
+
temp_c = job.get("temp_c", 27)
|
|
664
|
+
if not _is_number(temp_c):
|
|
665
|
+
raise AdapterError("invalid_command", f"Bad temp_c: {temp_c!r}.")
|
|
666
|
+
title = job.get("title", "rocket_engine run_post_layout")
|
|
667
|
+
if not _single_line(title):
|
|
668
|
+
raise AdapterError("invalid_command", f"Bad title: {title!r}.")
|
|
669
|
+
provenance = _check_provenance(
|
|
670
|
+
job.get("extraction_provenance"), extracted
|
|
671
|
+
)
|
|
672
|
+
schematic = self._schematic_values(job, measurements)
|
|
673
|
+
model_lib = self._resolve_model(job.get("pdk"))
|
|
674
|
+
checked = {
|
|
675
|
+
"ngspice": self._resolve(job),
|
|
676
|
+
"extracted_netlist": str(extracted),
|
|
677
|
+
"testbench": str(testbench) if testbench is not None else None,
|
|
678
|
+
"measurements": measurements,
|
|
679
|
+
"waveforms": waveforms,
|
|
680
|
+
"analyses": analyses,
|
|
681
|
+
"options": options,
|
|
682
|
+
"params": params,
|
|
683
|
+
"corner": corner,
|
|
684
|
+
"temp_c": temp_c,
|
|
685
|
+
"title": title.strip(),
|
|
686
|
+
"model_lib": model_lib,
|
|
687
|
+
"schematic": schematic,
|
|
688
|
+
"provenance": provenance,
|
|
689
|
+
"timeout_s": timeout,
|
|
690
|
+
}
|
|
691
|
+
if self._runner is not None:
|
|
692
|
+
try:
|
|
693
|
+
self._runner.preflight()
|
|
694
|
+
except ExecutionError as exc:
|
|
695
|
+
raise AdapterError(exc.code, str(exc)) from exc
|
|
696
|
+
return checked
|
|
697
|
+
|
|
698
|
+
def execute(self, job):
|
|
699
|
+
"""Stage the post-layout deck plus .spiceinit, then run ngspice.
|
|
700
|
+
|
|
701
|
+
Args:
|
|
702
|
+
job: Solver job with operation run_post_layout plus the
|
|
703
|
+
extracted netlist, provenance, measurements, and
|
|
704
|
+
schematic reference inputs.
|
|
705
|
+
|
|
706
|
+
Returns:
|
|
707
|
+
Native output with exit_code, stdout/stderr, log, deck,
|
|
708
|
+
extracted netlist, testbench, measurements, the
|
|
709
|
+
schematic sidecar, provenance, and the waveforms
|
|
710
|
+
spec plus file.
|
|
711
|
+
|
|
712
|
+
Raises:
|
|
713
|
+
AdapterError: invalid_reference for a missing workdir
|
|
714
|
+
or unreadable netlist, runner_unavailable on a sim
|
|
715
|
+
timeout, plus the preflight codes (execute
|
|
716
|
+
re-checks first).
|
|
717
|
+
"""
|
|
718
|
+
checked = self.preflight(job)
|
|
719
|
+
workdir = job.get("workdir", ".")
|
|
720
|
+
if not Path(workdir).is_dir():
|
|
721
|
+
raise AdapterError(
|
|
722
|
+
"invalid_reference", f"Workdir not found: {workdir}."
|
|
723
|
+
)
|
|
724
|
+
workdir_path = Path(workdir)
|
|
725
|
+
bodies = [_strip_end_lines(_read_source(checked["extracted_netlist"]))]
|
|
726
|
+
if checked["testbench"] is not None:
|
|
727
|
+
bodies.append(_strip_end_lines(_read_source(checked["testbench"])))
|
|
728
|
+
deck = build_deck(
|
|
729
|
+
checked["title"],
|
|
730
|
+
checked["corner"],
|
|
731
|
+
checked["temp_c"],
|
|
732
|
+
checked["params"],
|
|
733
|
+
checked["options"],
|
|
734
|
+
checked["model_lib"],
|
|
735
|
+
bodies,
|
|
736
|
+
checked["analyses"],
|
|
737
|
+
checked["measurements"],
|
|
738
|
+
checked["waveforms"],
|
|
739
|
+
)
|
|
740
|
+
(workdir_path / _DECK_NAME).write_text(deck, encoding="utf-8")
|
|
741
|
+
(workdir_path / _SPICEINIT_NAME).write_text(_SPICEINIT, encoding="utf-8")
|
|
742
|
+
roots = tool_roots([checked["ngspice"]])
|
|
743
|
+
proc = self._run(
|
|
744
|
+
[checked["ngspice"], "-b", "-o", _LOG_NAME, _DECK_NAME],
|
|
745
|
+
workdir,
|
|
746
|
+
checked["timeout_s"],
|
|
747
|
+
roots,
|
|
748
|
+
)
|
|
749
|
+
log_path = workdir_path / _LOG_NAME
|
|
750
|
+
log_text = log_path.read_text(encoding="utf-8", errors="replace") if (
|
|
751
|
+
log_path.is_file()
|
|
752
|
+
) else ""
|
|
753
|
+
return {
|
|
754
|
+
"exit_code": proc.returncode,
|
|
755
|
+
"stdout": proc.stdout,
|
|
756
|
+
"stderr": proc.stderr,
|
|
757
|
+
"log": log_text,
|
|
758
|
+
"deck": deck,
|
|
759
|
+
"extracted_netlist": checked["extracted_netlist"],
|
|
760
|
+
"testbench": checked["testbench"],
|
|
761
|
+
"measurements": checked["measurements"],
|
|
762
|
+
"schematic": checked["schematic"],
|
|
763
|
+
"provenance": checked["provenance"],
|
|
764
|
+
"waveforms_spec": checked["waveforms"],
|
|
765
|
+
"waveforms_file": str(workdir_path / _WAVEFORMS_NAME),
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
def parse(self, native_output):
|
|
769
|
+
"""Parse meas_* values, join the pre/post table, read waveforms.
|
|
770
|
+
|
|
771
|
+
Bound misses are FAIL (margin always engine-computed, never
|
|
772
|
+
left to the caller); expected measurements with no value
|
|
773
|
+
synthesize sim_fail violations quoting ngspice's error text.
|
|
774
|
+
The comparison joins each post value with its schematic pre
|
|
775
|
+
value (delta = post - pre); deltas never gate the verdict.
|
|
776
|
+
Waveforms parse from the wrdata file when present and read
|
|
777
|
+
as empty otherwise — auxiliary evidence, never a failure.
|
|
778
|
+
|
|
779
|
+
Args:
|
|
780
|
+
native_output: Native output with stdout/stderr/log, the
|
|
781
|
+
extracted netlist, the expected measurements, the
|
|
782
|
+
schematic sidecar, provenance, and the waveforms
|
|
783
|
+
spec plus file.
|
|
784
|
+
|
|
785
|
+
Returns:
|
|
786
|
+
{total, violations[], measurements[], comparison[],
|
|
787
|
+
waveforms[], provenance, source} with value, unit,
|
|
788
|
+
target, margin, and status per measurement plus
|
|
789
|
+
pre, post, delta, and rel per comparison row;
|
|
790
|
+
source names the channel read (`log` for the -o
|
|
791
|
+
file, `stdio_fallback` else).
|
|
792
|
+
|
|
793
|
+
Raises:
|
|
794
|
+
AdapterError: runner_unavailable when ngspice produced
|
|
795
|
+
no output at all or the native carries no
|
|
796
|
+
schematic sidecar.
|
|
797
|
+
"""
|
|
798
|
+
# MIGRATE-STDIO-FILE tier 1: the `-o postlayout.log` file
|
|
799
|
+
# is the parse channel; stdio survives only as a loud
|
|
800
|
+
# fallback for natives recorded before the file existed.
|
|
801
|
+
log = native_output.get("log") or ""
|
|
802
|
+
if log.strip():
|
|
803
|
+
combined, source = log, "log"
|
|
804
|
+
else:
|
|
805
|
+
stdio = "\n".join(
|
|
806
|
+
[native_output.get("stdout") or "",
|
|
807
|
+
native_output.get("stderr") or ""]
|
|
808
|
+
)
|
|
809
|
+
if not stdio.strip():
|
|
810
|
+
raise AdapterError(
|
|
811
|
+
"runner_unavailable", "ngspice produced no output at all."
|
|
812
|
+
)
|
|
813
|
+
combined, source = stdio, "stdio_fallback"
|
|
814
|
+
values = {}
|
|
815
|
+
for match in _MEAS_RE.finditer(combined):
|
|
816
|
+
try:
|
|
817
|
+
values[match.group(1)] = float(match.group(2))
|
|
818
|
+
except ValueError:
|
|
819
|
+
continue
|
|
820
|
+
expected = native_output.get("measurements") or []
|
|
821
|
+
waveforms = _parse_waveforms_file(
|
|
822
|
+
native_output.get("waveforms_file") or "",
|
|
823
|
+
native_output.get("waveforms_spec") or [],
|
|
824
|
+
)
|
|
825
|
+
provenance = self._provenance_echo(native_output)
|
|
826
|
+
missing = [item["name"] for item in expected if item["name"] not in values]
|
|
827
|
+
if missing:
|
|
828
|
+
errors = [
|
|
829
|
+
line.strip()
|
|
830
|
+
for line in combined.splitlines()
|
|
831
|
+
if _ERROR_RE.search(line)
|
|
832
|
+
]
|
|
833
|
+
detail = (
|
|
834
|
+
f" ngspice said: {errors[0]}"
|
|
835
|
+
if errors
|
|
836
|
+
else " (no meas lines and no ngspice error text)"
|
|
837
|
+
)
|
|
838
|
+
first = native_output.get("extracted_netlist") or ""
|
|
839
|
+
violations = [
|
|
840
|
+
{
|
|
841
|
+
"rule": "sim_fail",
|
|
842
|
+
"severity": "error",
|
|
843
|
+
"message": (
|
|
844
|
+
f"ngspice produced no value for {name}.{detail}"
|
|
845
|
+
),
|
|
846
|
+
"file": first,
|
|
847
|
+
}
|
|
848
|
+
for name in missing
|
|
849
|
+
]
|
|
850
|
+
return {
|
|
851
|
+
"total": len(violations),
|
|
852
|
+
"violations": violations,
|
|
853
|
+
"comparison": [],
|
|
854
|
+
"waveforms": waveforms,
|
|
855
|
+
"provenance": provenance,
|
|
856
|
+
"source": source,
|
|
857
|
+
}
|
|
858
|
+
schematic = native_output.get("schematic") or {}
|
|
859
|
+
pre_values = schematic.get("values") or {}
|
|
860
|
+
if not schematic.get("run_id") or not pre_values:
|
|
861
|
+
raise AdapterError(
|
|
862
|
+
"runner_unavailable",
|
|
863
|
+
"post-layout native has no schematic sidecar.",
|
|
864
|
+
)
|
|
865
|
+
first = native_output.get("extracted_netlist") or ""
|
|
866
|
+
violations = []
|
|
867
|
+
measurements = []
|
|
868
|
+
comparison = []
|
|
869
|
+
for item in expected:
|
|
870
|
+
actual = values[item["name"]]
|
|
871
|
+
bounds = {
|
|
872
|
+
key: item[key]
|
|
873
|
+
for key in ("min", "max")
|
|
874
|
+
if item.get(key) is not None
|
|
875
|
+
}
|
|
876
|
+
margins = {}
|
|
877
|
+
if "min" in bounds:
|
|
878
|
+
margins["min"] = actual - bounds["min"]
|
|
879
|
+
if "max" in bounds:
|
|
880
|
+
margins["max"] = bounds["max"] - actual
|
|
881
|
+
failed = [
|
|
882
|
+
f"{kind} {bounds[kind]}"
|
|
883
|
+
for kind, margin in margins.items()
|
|
884
|
+
if margin < 0
|
|
885
|
+
]
|
|
886
|
+
status = "fail" if failed else "pass"
|
|
887
|
+
margin = min(margins.values())
|
|
888
|
+
unit = item.get("unit") or ""
|
|
889
|
+
measurements.append(
|
|
890
|
+
{
|
|
891
|
+
"name": item["name"],
|
|
892
|
+
"value": actual,
|
|
893
|
+
"unit": unit,
|
|
894
|
+
"target": dict(bounds),
|
|
895
|
+
"margin": margin,
|
|
896
|
+
"status": status,
|
|
897
|
+
}
|
|
898
|
+
)
|
|
899
|
+
if failed:
|
|
900
|
+
unit_suffix = f" {unit}" if unit else ""
|
|
901
|
+
violations.append(
|
|
902
|
+
{
|
|
903
|
+
"rule": "measurement",
|
|
904
|
+
"severity": "error",
|
|
905
|
+
"message": (
|
|
906
|
+
f"{item['name']} = {actual:.6g}{unit_suffix} "
|
|
907
|
+
f"misses {', '.join(failed)}{unit_suffix} "
|
|
908
|
+
f"(margin {margin:.6g}{unit_suffix})."
|
|
909
|
+
),
|
|
910
|
+
"file": first,
|
|
911
|
+
}
|
|
912
|
+
)
|
|
913
|
+
pre = pre_values[item["name"]]["value"]
|
|
914
|
+
delta = actual - pre
|
|
915
|
+
comparison.append(
|
|
916
|
+
{
|
|
917
|
+
"name": item["name"],
|
|
918
|
+
"unit": unit,
|
|
919
|
+
"pre": pre,
|
|
920
|
+
"post": actual,
|
|
921
|
+
"delta": delta,
|
|
922
|
+
"rel": delta / pre if pre != 0 else None,
|
|
923
|
+
"status": status,
|
|
924
|
+
}
|
|
925
|
+
)
|
|
926
|
+
return {
|
|
927
|
+
"total": len(violations),
|
|
928
|
+
"violations": violations,
|
|
929
|
+
"measurements": measurements,
|
|
930
|
+
"comparison": comparison,
|
|
931
|
+
"waveforms": waveforms,
|
|
932
|
+
"provenance": provenance,
|
|
933
|
+
"source": source,
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
def _schematic_values(self, job, measurements):
|
|
937
|
+
schem_id = job.get("schematic_run_id")
|
|
938
|
+
alias = job.get("run_id")
|
|
939
|
+
if schem_id is not None and alias is not None and schem_id != alias:
|
|
940
|
+
raise AdapterError(
|
|
941
|
+
"invalid_command",
|
|
942
|
+
"schematic_run_id and run_id disagree "
|
|
943
|
+
f"({schem_id!r} vs {alias!r}).",
|
|
944
|
+
)
|
|
945
|
+
run_id = schem_id if schem_id is not None else alias
|
|
946
|
+
if not isinstance(run_id, str) or not run_id:
|
|
947
|
+
raise AdapterError(
|
|
948
|
+
"invalid_command",
|
|
949
|
+
"run_post_layout needs schematic_run_id (the schematic "
|
|
950
|
+
"run_spice run id the comparison reads pre values for).",
|
|
951
|
+
)
|
|
952
|
+
explicit = job.get("schematic_measurements")
|
|
953
|
+
if explicit is not None:
|
|
954
|
+
values = _clean_value_list(
|
|
955
|
+
explicit, "schematic_measurements", "invalid_command"
|
|
956
|
+
)
|
|
957
|
+
elif self._runs is not None:
|
|
958
|
+
try:
|
|
959
|
+
raw = self._runs(run_id)
|
|
960
|
+
except AdapterError:
|
|
961
|
+
raise
|
|
962
|
+
except (KeyError, LookupError):
|
|
963
|
+
raise AdapterError(
|
|
964
|
+
"invalid_reference",
|
|
965
|
+
f"Unknown schematic run: {run_id} (no measurements).",
|
|
966
|
+
) from None
|
|
967
|
+
values = _normalize_runs_result(raw, run_id)
|
|
968
|
+
else:
|
|
969
|
+
raise AdapterError(
|
|
970
|
+
"invalid_reference",
|
|
971
|
+
f"Unknown schematic run: {run_id} (pass "
|
|
972
|
+
"schematic_measurements or a runs resolver).",
|
|
973
|
+
)
|
|
974
|
+
for item in measurements:
|
|
975
|
+
if item["name"] not in values:
|
|
976
|
+
raise AdapterError(
|
|
977
|
+
"invalid_command",
|
|
978
|
+
f"Schematic run {run_id} has no value for "
|
|
979
|
+
f"measurement {item['name']!r}.",
|
|
980
|
+
)
|
|
981
|
+
return {"run_id": run_id, "values": values}
|
|
982
|
+
|
|
983
|
+
@staticmethod
|
|
984
|
+
def _provenance_echo(native_output):
|
|
985
|
+
record = native_output.get("provenance") or {}
|
|
986
|
+
schematic = native_output.get("schematic") or {}
|
|
987
|
+
echo = {
|
|
988
|
+
"extraction_run_id": record.get("extraction_run_id"),
|
|
989
|
+
"netlist_sha256": record.get("netlist_sha256"),
|
|
990
|
+
"schematic_run_id": schematic.get("run_id"),
|
|
991
|
+
}
|
|
992
|
+
for key in ("cell", "tech", "spice_file", "device_count"):
|
|
993
|
+
if record.get(key) is not None:
|
|
994
|
+
echo[key] = record[key]
|
|
995
|
+
return echo
|
|
996
|
+
|
|
997
|
+
@staticmethod
|
|
998
|
+
def _resolve_model(pdk):
|
|
999
|
+
if pdk is None:
|
|
1000
|
+
return None
|
|
1001
|
+
if not isinstance(pdk, dict):
|
|
1002
|
+
raise AdapterError(
|
|
1003
|
+
"invalid_command", f"PDK pin must be a mapping: {pdk!r}."
|
|
1004
|
+
)
|
|
1005
|
+
pdk_id = pdk.get("id")
|
|
1006
|
+
revision = pdk.get("revision")
|
|
1007
|
+
if not pdk_id or not revision:
|
|
1008
|
+
raise AdapterError(
|
|
1009
|
+
"invalid_command",
|
|
1010
|
+
f"PDK pin needs id and revision: {pdk!r}.",
|
|
1011
|
+
)
|
|
1012
|
+
rel = pdk.get("model_rel", _MODEL_REL)
|
|
1013
|
+
try:
|
|
1014
|
+
hit = resolve_deck(
|
|
1015
|
+
pdk_id, revision, rel, engine_root=pdk.get("engine_root")
|
|
1016
|
+
)
|
|
1017
|
+
except Exception as exc:
|
|
1018
|
+
# PdkError codes (unknown_pdk, pdk_drift, ...) ride
|
|
1019
|
+
# through unchanged so a model mismatch fails loudly
|
|
1020
|
+
# with its store meaning, never as a generic error.
|
|
1021
|
+
raise AdapterError(
|
|
1022
|
+
getattr(exc, "code", "unknown_pdk"), str(exc)
|
|
1023
|
+
) from exc
|
|
1024
|
+
return hit["path"]
|
|
1025
|
+
|
|
1026
|
+
@staticmethod
|
|
1027
|
+
def _resolve(job):
|
|
1028
|
+
tool_dir = job.get("tool_dir")
|
|
1029
|
+
if tool_dir:
|
|
1030
|
+
candidate = Path(tool_dir) / "ngspice"
|
|
1031
|
+
if candidate.is_file():
|
|
1032
|
+
return str(candidate)
|
|
1033
|
+
raise AdapterError(
|
|
1034
|
+
"missing_dependency", f"ngspice not found in {tool_dir}."
|
|
1035
|
+
)
|
|
1036
|
+
found = shutil.which("ngspice")
|
|
1037
|
+
if found is None:
|
|
1038
|
+
raise AdapterError(
|
|
1039
|
+
"missing_dependency",
|
|
1040
|
+
"ngspice not on PATH (pass tool_dir or integrate via B10).",
|
|
1041
|
+
)
|
|
1042
|
+
return found
|
|
1043
|
+
|
|
1044
|
+
def _run(self, argv, workdir, timeout_s, extra_binds=()):
|
|
1045
|
+
if self._runner is not None:
|
|
1046
|
+
try:
|
|
1047
|
+
return self._runner.run(argv, workdir, timeout_s, extra_binds)
|
|
1048
|
+
except ExecutionError as exc:
|
|
1049
|
+
raise AdapterError(exc.code, str(exc)) from exc
|
|
1050
|
+
try:
|
|
1051
|
+
return subprocess.run(
|
|
1052
|
+
argv,
|
|
1053
|
+
capture_output=True,
|
|
1054
|
+
text=True,
|
|
1055
|
+
timeout=timeout_s,
|
|
1056
|
+
cwd=workdir,
|
|
1057
|
+
)
|
|
1058
|
+
except subprocess.TimeoutExpired as exc:
|
|
1059
|
+
raise AdapterError(
|
|
1060
|
+
"runner_unavailable",
|
|
1061
|
+
f"post-layout sim timed out after {timeout_s}s.",
|
|
1062
|
+
) from exc
|