traceforge-pcb 1.0.0__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.
- traceforge/__init__.py +3 -0
- traceforge/__main__.py +3 -0
- traceforge/acp_server.py +570 -0
- traceforge/agent.py +680 -0
- traceforge/altium/__init__.py +6 -0
- traceforge/altium/cfb.py +70 -0
- traceforge/altium/cli.py +60 -0
- traceforge/altium/model.py +163 -0
- traceforge/altium/outputs.py +24 -0
- traceforge/altium/pcb.py +301 -0
- traceforge/altium/project.py +328 -0
- traceforge/altium/schematic.py +400 -0
- traceforge/altium/tools.py +166 -0
- traceforge/approval.py +219 -0
- traceforge/attachments.py +346 -0
- traceforge/background.py +253 -0
- traceforge/bench/__init__.py +5 -0
- traceforge/bench/adapters.py +337 -0
- traceforge/bench/checks.py +410 -0
- traceforge/bench/cli.py +121 -0
- traceforge/bench/design.py +439 -0
- traceforge/bench/kicad.py +99 -0
- traceforge/bench/partscheck.py +159 -0
- traceforge/bench/review.py +100 -0
- traceforge/bench/runner.py +226 -0
- traceforge/bench/score.py +204 -0
- traceforge/bench/tasks.py +102 -0
- traceforge/chat.py +1185 -0
- traceforge/checks.py +328 -0
- traceforge/checks_sides.py +118 -0
- traceforge/cli.py +2224 -0
- traceforge/clipboard.py +107 -0
- traceforge/config.py +299 -0
- traceforge/context.py +276 -0
- traceforge/design/__init__.py +1 -0
- traceforge/design/constraints.py +397 -0
- traceforge/design/generate.py +1789 -0
- traceforge/design/outline.py +905 -0
- traceforge/design/schematic_layout.py +932 -0
- traceforge/design/schematic_lint.py +329 -0
- traceforge/design/schematic_route.py +207 -0
- traceforge/design/sheets.py +854 -0
- traceforge/design/sides.py +328 -0
- traceforge/design/spec.py +462 -0
- traceforge/design/specsheet.py +2351 -0
- traceforge/design/specsheet_render.py +183 -0
- traceforge/desktop/__init__.py +109 -0
- traceforge/desktop/keys.py +90 -0
- traceforge/desktop/project.py +443 -0
- traceforge/desktop/review.py +20 -0
- traceforge/desktop/server.py +1277 -0
- traceforge/desktop/static/app.css +223 -0
- traceforge/desktop/static/app.js +683 -0
- traceforge/desktop/static/highlight.js +77 -0
- traceforge/desktop/static/index.html +89 -0
- traceforge/desktop/static/md.js +90 -0
- traceforge/desktop/static/viewer.js +213 -0
- traceforge/desktop/symbols.py +212 -0
- traceforge/desktop/update.py +65 -0
- traceforge/diagnostics.py +67 -0
- traceforge/errata.py +219 -0
- traceforge/fab.py +108 -0
- traceforge/fab_layers.py +44 -0
- traceforge/features/__init__.py +6 -0
- traceforge/features/candidates.py +117 -0
- traceforge/features/cli.py +200 -0
- traceforge/features/package.py +433 -0
- traceforge/features/revdiff.py +224 -0
- traceforge/features/sarif.py +145 -0
- traceforge/features/viewer.py +265 -0
- traceforge/firmware/__init__.py +3 -0
- traceforge/firmware/cli.py +174 -0
- traceforge/firmware/drivers.py +372 -0
- traceforge/firmware/drivers_data.py +256 -0
- traceforge/firmware/mcus.py +177 -0
- traceforge/firmware/netlist.py +55 -0
- traceforge/firmware/pinmap.py +296 -0
- traceforge/firmware/scaffold.py +466 -0
- traceforge/firmware/toolchain.py +262 -0
- traceforge/hooks.py +237 -0
- traceforge/kicad/__init__.py +0 -0
- traceforge/kicad/cli.py +519 -0
- traceforge/kicad/diffpair.py +174 -0
- traceforge/kicad/edgegeom.py +191 -0
- traceforge/kicad/edit.py +405 -0
- traceforge/kicad/escape.py +1410 -0
- traceforge/kicad/formats.py +1073 -0
- traceforge/kicad/impedance.py +320 -0
- traceforge/kicad/install.py +75 -0
- traceforge/kicad/libraries.py +490 -0
- traceforge/kicad/models3d.py +942 -0
- traceforge/kicad/outputs.py +81 -0
- traceforge/kicad/pcb.py +787 -0
- traceforge/kicad/planes.py +1242 -0
- traceforge/kicad/project.py +136 -0
- traceforge/kicad/route.py +2698 -0
- traceforge/kicad/routegeom.py +164 -0
- traceforge/kicad/schematic.py +268 -0
- traceforge/kicad/sides.py +34 -0
- traceforge/kicad/stackup.py +424 -0
- traceforge/kicad_plugin/__init__.py +12 -0
- traceforge/kicad_plugin/actions.py +92 -0
- traceforge/kicad_plugin/cli.py +66 -0
- traceforge/kicad_plugin/client.py +86 -0
- traceforge/kicad_plugin/context.py +257 -0
- traceforge/kicad_plugin/discovery.py +81 -0
- traceforge/kicad_plugin/install.py +203 -0
- traceforge/kicad_plugin/overlay.py +60 -0
- traceforge/kicad_plugin/payload/action_plugin.py +65 -0
- traceforge/kicad_plugin/payload/ipc_main.py +42 -0
- traceforge/kicad_plugin/payload/plugin.json +90 -0
- traceforge/kicad_plugin/payload/requirements.txt +1 -0
- traceforge/kicad_plugin/views.py +202 -0
- traceforge/langchain.py +57 -0
- traceforge/local.py +580 -0
- traceforge/mcp_server.py +304 -0
- traceforge/models.py +310 -0
- traceforge/next_steps.py +110 -0
- traceforge/parts/__init__.py +52 -0
- traceforge/parts/alternates.py +488 -0
- traceforge/parts/bom.py +423 -0
- traceforge/parts/brief.py +705 -0
- traceforge/parts/catalog.py +297 -0
- traceforge/parts/cli_sourcing.py +156 -0
- traceforge/parts/data/duty_rates.yaml +37 -0
- traceforge/parts/data/hs_codes.yaml +103 -0
- traceforge/parts/datasheet.py +278 -0
- traceforge/parts/fx.py +100 -0
- traceforge/parts/model.py +185 -0
- traceforge/parts/net.py +187 -0
- traceforge/parts/offers.py +46 -0
- traceforge/parts/packages.py +397 -0
- traceforge/parts/pinout.py +714 -0
- traceforge/parts/providers/__init__.py +17 -0
- traceforge/parts/providers/base.py +100 -0
- traceforge/parts/providers/jlc.py +364 -0
- traceforge/parts/providers/keyed.py +661 -0
- traceforge/parts/report.py +508 -0
- traceforge/parts/supply.py +330 -0
- traceforge/parts/verify.py +455 -0
- traceforge/parts/web.py +575 -0
- traceforge/plans.py +70 -0
- traceforge/preferences.py +127 -0
- traceforge/prices.py +130 -0
- traceforge/prices.yaml +28 -0
- traceforge/prompts/__init__.py +54 -0
- traceforge/prompts/system.md +103 -0
- traceforge/providers.py +764 -0
- traceforge/remote/__init__.py +33 -0
- traceforge/remote/client.py +422 -0
- traceforge/remote/oidc.py +240 -0
- traceforge/remote/orgs.py +510 -0
- traceforge/remote/server.py +690 -0
- traceforge/remote/store.py +100 -0
- traceforge/review_changes.py +187 -0
- traceforge/routing.py +99 -0
- traceforge/runtime.py +435 -0
- traceforge/sessions.py +324 -0
- traceforge/sexpr.py +279 -0
- traceforge/sim/__init__.py +17 -0
- traceforge/sim/acmath.py +234 -0
- traceforge/sim/circuit.py +499 -0
- traceforge/sim/cli.py +162 -0
- traceforge/sim/engine.py +172 -0
- traceforge/sim/ltspice.py +175 -0
- traceforge/sim/measure.py +123 -0
- traceforge/sim/models.py +76 -0
- traceforge/sim/project.py +218 -0
- traceforge/sim/rawfile.py +123 -0
- traceforge/sim/results.py +298 -0
- traceforge/sim/tune.py +172 -0
- traceforge/skill_commands.py +165 -0
- traceforge/skill_library/circuit-simulation/SKILL.md +30 -0
- traceforge/skill_library/compliance-review/SKILL.md +51 -0
- traceforge/skill_library/design-review/SKILL.md +47 -0
- traceforge/skill_library/errata-hunt/SKILL.md +30 -0
- traceforge/skill_library/firmware/SKILL.md +26 -0
- traceforge/skill_library/power-budget/SKILL.md +27 -0
- traceforge/skill_library/release-package/SKILL.md +29 -0
- traceforge/skill_library/sourcing-brief/SKILL.md +24 -0
- traceforge/skill_library/thermal-review/SKILL.md +15 -0
- traceforge/skills.py +131 -0
- traceforge/thermal.py +557 -0
- traceforge/tools/__init__.py +48 -0
- traceforge/tools/agent_tools.py +213 -0
- traceforge/tools/base.py +303 -0
- traceforge/tools/batch.py +155 -0
- traceforge/tools/connections.py +245 -0
- traceforge/tools/files.py +777 -0
- traceforge/tools/firmware_tools.py +218 -0
- traceforge/tools/kicad_tools.py +1072 -0
- traceforge/tools/models3d_tools.py +141 -0
- traceforge/tools/parts_tools.py +473 -0
- traceforge/tools/processes.py +246 -0
- traceforge/tools/review_tools.py +178 -0
- traceforge/tools/sim_tools.py +230 -0
- traceforge/tui.py +620 -0
- traceforge/updates.py +124 -0
- traceforge/validate/__init__.py +77 -0
- traceforge/validate/builtins.py +1091 -0
- traceforge/validate/circuits.py +1240 -0
- traceforge/validate/config.py +410 -0
- traceforge/validate/default.yaml +113 -0
- traceforge/validate/design.py +191 -0
- traceforge/validate/explain.py +112 -0
- traceforge/validate/grounding.py +169 -0
- traceforge/validate/ipc2221.py +350 -0
- traceforge/validate/multilayer.py +805 -0
- traceforge/validate/netmodel.py +261 -0
- traceforge/validate/partcheck.py +472 -0
- traceforge/validate/report.py +280 -0
- traceforge/validate/review.py +322 -0
- traceforge/validate/runner.py +662 -0
- traceforge/workspace.py +171 -0
- traceforge_pcb-1.0.0.dist-info/METADATA +463 -0
- traceforge_pcb-1.0.0.dist-info/RECORD +219 -0
- traceforge_pcb-1.0.0.dist-info/WHEEL +4 -0
- traceforge_pcb-1.0.0.dist-info/entry_points.txt +2 -0
- traceforge_pcb-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
"""A SPICE circuit as editable data: parse, change values, write, or import from KiCad."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from traceforge import sexpr
|
|
10
|
+
from traceforge.kicad.pcb import pin_function
|
|
11
|
+
from traceforge.sim import models
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SimError(Exception):
|
|
15
|
+
"""A simulation problem the caller can act on."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# --------------------------------------------------------------------------- numbers
|
|
19
|
+
_SUFFIX = {
|
|
20
|
+
"t": 1e12,
|
|
21
|
+
"g": 1e9,
|
|
22
|
+
"meg": 1e6,
|
|
23
|
+
"k": 1e3,
|
|
24
|
+
"m": 1e-3,
|
|
25
|
+
"u": 1e-6,
|
|
26
|
+
"n": 1e-9,
|
|
27
|
+
"p": 1e-12,
|
|
28
|
+
"f": 1e-15,
|
|
29
|
+
}
|
|
30
|
+
_NUM = re.compile(r"^\s*([-+]?(?:\d+\.?\d*|\.\d+)(?:e[-+]?\d+)?)\s*([a-zµμ]*)", re.I)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def parse_value(text: str | float | int) -> float:
|
|
34
|
+
"""SPICE/KiCad number: ``4.7k``, ``10u``, ``100nF``, ``1meg``, ``4k7``, ``2.2µ``."""
|
|
35
|
+
if isinstance(text, int | float):
|
|
36
|
+
return float(text)
|
|
37
|
+
s = str(text).strip().replace("µ", "u").replace("μ", "u")
|
|
38
|
+
m = re.fullmatch(r"(\d+)(meg|[tgkmunpf])(\d+)", s, re.I) # 4k7 style
|
|
39
|
+
if m:
|
|
40
|
+
s = f"{m.group(1)}.{m.group(3)}{m.group(2)}"
|
|
41
|
+
m = _NUM.match(s)
|
|
42
|
+
if not m:
|
|
43
|
+
raise ValueError(f"not a number: {text!r}")
|
|
44
|
+
number, suffix = float(m.group(1)), m.group(2).lower()
|
|
45
|
+
if not suffix:
|
|
46
|
+
return number
|
|
47
|
+
if suffix.startswith("meg"):
|
|
48
|
+
return number * 1e6
|
|
49
|
+
return number * _SUFFIX.get(suffix[0], 1.0) # trailing unit letters (F, H, ohm) ignored
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def format_value(x: float, digits: int = 6) -> str:
|
|
53
|
+
"""Shortest SPICE-safe number (``31.83k`` style would hide digits, so plain notation)."""
|
|
54
|
+
return f"{x:.{digits}g}"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# ---------------------------------------------------------------------------- model
|
|
58
|
+
_NODES = {"R": 2, "C": 2, "L": 2, "V": 2, "I": 2, "D": 2, "B": 2, "F": 2, "H": 2, "Q": 3, "M": 4}
|
|
59
|
+
_NODES |= {"E": 4, "G": 4, "S": 4, "W": 2, "J": 3, "Z": 3, "O": 4, "T": 4, "K": 0}
|
|
60
|
+
GROUND_NAMES = {"0", "GND", "GNDA", "GNDD", "AGND", "DGND", "GROUND", "VSS"}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class Element:
|
|
65
|
+
ref: str
|
|
66
|
+
nodes: list[str]
|
|
67
|
+
value: str = ""
|
|
68
|
+
note: str = "" # e.g. "generic LED model"
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def kind(self) -> str:
|
|
72
|
+
return self.ref[:1].upper()
|
|
73
|
+
|
|
74
|
+
def line(self) -> str:
|
|
75
|
+
return " ".join([self.ref, *self.nodes, self.value]).strip()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass
|
|
79
|
+
class Circuit:
|
|
80
|
+
title: str = "traceforge circuit"
|
|
81
|
+
elements: list[Element] = field(default_factory=list)
|
|
82
|
+
params: dict[str, str] = field(default_factory=dict) # .param name=value
|
|
83
|
+
blocks: dict[str, str] = field(default_factory=dict) # .model / .subckt text by name
|
|
84
|
+
includes: list[str] = field(default_factory=list) # full .include / .lib lines
|
|
85
|
+
directives: list[str] = field(default_factory=list) # other dot lines (.ic, .options, ...)
|
|
86
|
+
node_map: dict[str, str] = field(default_factory=dict) # schematic net -> SPICE node
|
|
87
|
+
skipped: list[str] = field(default_factory=list) # parts left out, with the reason
|
|
88
|
+
generic: list[str] = field(default_factory=list) # parts on a generic stand-in model
|
|
89
|
+
warnings: list[str] = field(default_factory=list)
|
|
90
|
+
|
|
91
|
+
# ----------------------------------------------------------------- access
|
|
92
|
+
def element(self, ref: str) -> Element:
|
|
93
|
+
for el in self.elements:
|
|
94
|
+
if el.ref.upper() == ref.upper():
|
|
95
|
+
return el
|
|
96
|
+
raise SimError(f"no element {ref!r} in the circuit")
|
|
97
|
+
|
|
98
|
+
def number(self, ref: str) -> float:
|
|
99
|
+
"""Numeric value of R/C/L element or of ``param:NAME``."""
|
|
100
|
+
if ref.lower().startswith("param:"):
|
|
101
|
+
return parse_value(self.params[ref[6:]])
|
|
102
|
+
return parse_value(self.element(ref).value.split()[0])
|
|
103
|
+
|
|
104
|
+
def set_value(self, ref: str, value: float | str) -> None:
|
|
105
|
+
"""Set an element value (``R1``), a tied group (``R1,R2``) or ``param:NAME``."""
|
|
106
|
+
text = value if isinstance(value, str) else format_value(value)
|
|
107
|
+
for name in (n.strip() for n in ref.split(",") if n.strip()):
|
|
108
|
+
if name.lower().startswith("param:"):
|
|
109
|
+
self.params[name[6:]] = text
|
|
110
|
+
continue
|
|
111
|
+
el = self.element(name)
|
|
112
|
+
rest = el.value.split(maxsplit=1)
|
|
113
|
+
el.value = text + (" " + rest[1] if len(rest) > 1 else "")
|
|
114
|
+
|
|
115
|
+
def copy(self) -> Circuit:
|
|
116
|
+
import copy
|
|
117
|
+
|
|
118
|
+
return copy.deepcopy(self)
|
|
119
|
+
|
|
120
|
+
def node(self, net: str) -> str:
|
|
121
|
+
"""SPICE node name for a schematic net or an already-SPICE node."""
|
|
122
|
+
return self.node_map.get(net, net)
|
|
123
|
+
|
|
124
|
+
# ------------------------------------------------------------------ output
|
|
125
|
+
def to_spice(self, extra: list[str] | None = None, control: list[str] | None = None) -> str:
|
|
126
|
+
out = [f"* {self.title}" if not self.title.startswith("*") else self.title]
|
|
127
|
+
out += [f"* generic: {g}" for g in self.generic]
|
|
128
|
+
out += [f"* skipped: {s}" for s in self.skipped]
|
|
129
|
+
out += self.includes
|
|
130
|
+
out += [f".param {k}={v}" for k, v in self.params.items()]
|
|
131
|
+
out += list(self.blocks.values())
|
|
132
|
+
out += self.directives
|
|
133
|
+
out += [el.line() for el in self.elements]
|
|
134
|
+
out += extra or []
|
|
135
|
+
if control:
|
|
136
|
+
out += [".control", *control, ".endc"]
|
|
137
|
+
out.append(".end")
|
|
138
|
+
return "\n".join(out) + "\n"
|
|
139
|
+
|
|
140
|
+
# ----------------------------------------------------------------- parsing
|
|
141
|
+
@classmethod
|
|
142
|
+
def from_text(cls, text: str, base: Path | None = None) -> Circuit:
|
|
143
|
+
"""Parse a SPICE deck. Analysis lines (.ac, .tran, ...) and .control blocks are kept
|
|
144
|
+
out of the circuit; pass them as an analysis instead."""
|
|
145
|
+
lines: list[str] = []
|
|
146
|
+
for raw in text.replace("\r", "").split("\n"):
|
|
147
|
+
if raw.startswith("+") and lines:
|
|
148
|
+
lines[-1] += " " + raw[1:].strip()
|
|
149
|
+
else:
|
|
150
|
+
lines.append(raw.rstrip())
|
|
151
|
+
circuit = cls()
|
|
152
|
+
if lines and not lines[0].lstrip().startswith(("*", ".")) and lines[0].strip():
|
|
153
|
+
first = lines[0].strip()
|
|
154
|
+
circuit.title = first
|
|
155
|
+
lines = lines[1:]
|
|
156
|
+
elif lines and lines[0].lower().startswith(".title"):
|
|
157
|
+
circuit.title = lines[0][6:].strip()
|
|
158
|
+
lines = lines[1:]
|
|
159
|
+
elif lines and lines[0].startswith("*"):
|
|
160
|
+
circuit.title = lines[0].lstrip("* ").strip()
|
|
161
|
+
lines = lines[1:]
|
|
162
|
+
block: list[str] | None = None
|
|
163
|
+
block_name = ""
|
|
164
|
+
in_control = False
|
|
165
|
+
for line in lines:
|
|
166
|
+
s = line.strip()
|
|
167
|
+
low = s.lower()
|
|
168
|
+
if not s or s.startswith(("*", ";")):
|
|
169
|
+
continue
|
|
170
|
+
if in_control:
|
|
171
|
+
in_control = not low.startswith(".endc")
|
|
172
|
+
continue
|
|
173
|
+
if low.startswith(".control"):
|
|
174
|
+
in_control = True
|
|
175
|
+
continue
|
|
176
|
+
if block is not None:
|
|
177
|
+
block.append(s)
|
|
178
|
+
if low.startswith(".ends"):
|
|
179
|
+
circuit.blocks[block_name] = "\n".join(block)
|
|
180
|
+
block = None
|
|
181
|
+
continue
|
|
182
|
+
if low.startswith(".subckt"):
|
|
183
|
+
block, block_name = [s], s.split()[1]
|
|
184
|
+
continue
|
|
185
|
+
if low.startswith(".end"):
|
|
186
|
+
break
|
|
187
|
+
if low.startswith(".model"):
|
|
188
|
+
circuit.blocks[s.split()[1]] = s
|
|
189
|
+
elif low.startswith((".include", ".inc", ".lib")):
|
|
190
|
+
circuit.includes.append(_absolute_include(s, base))
|
|
191
|
+
elif low.startswith(".param"):
|
|
192
|
+
for k, v in re.findall(r"([A-Za-z_]\w*)\s*=\s*(\{[^}]*\}|\S+)", s[6:]):
|
|
193
|
+
circuit.params[k] = v
|
|
194
|
+
elif low.startswith((".ac", ".tran", ".dc", ".op", ".noise", ".pz", ".tf", ".meas")):
|
|
195
|
+
circuit.directives.append(s) # kept so a deck round-trips; analyses are re-added
|
|
196
|
+
elif s.startswith("."):
|
|
197
|
+
circuit.directives.append(s)
|
|
198
|
+
else:
|
|
199
|
+
circuit.elements.append(_parse_element(s))
|
|
200
|
+
return circuit
|
|
201
|
+
|
|
202
|
+
@classmethod
|
|
203
|
+
def from_file(cls, path: str | Path) -> Circuit:
|
|
204
|
+
path = Path(path)
|
|
205
|
+
return cls.from_text(path.read_text(encoding="utf-8", errors="replace"), path.parent)
|
|
206
|
+
|
|
207
|
+
# ------------------------------------------------------------ analysis lines
|
|
208
|
+
def deck_directives(self) -> tuple[list[str], list[str]]:
|
|
209
|
+
"""Split stored dot lines into (analysis/.meas lines, others)."""
|
|
210
|
+
analysis = [d for d in self.directives if d.lower().startswith(_ANALYSIS_DOTS)]
|
|
211
|
+
return analysis, [d for d in self.directives if d not in analysis]
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
_ANALYSIS_DOTS = (".ac", ".tran", ".dc", ".op", ".noise", ".pz", ".tf", ".meas")
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _absolute_include(line: str, base: Path | None) -> str:
|
|
218
|
+
if base is None:
|
|
219
|
+
return line
|
|
220
|
+
m = re.match(r'(\.\w+)\s+("?)([^"\s]+)\2(.*)', line)
|
|
221
|
+
if not m:
|
|
222
|
+
return line
|
|
223
|
+
path = Path(m.group(3))
|
|
224
|
+
if not path.is_absolute() and (base / path).exists():
|
|
225
|
+
path = (base / path).resolve()
|
|
226
|
+
return f'{m.group(1)} "{path}"{m.group(4)}'
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _parse_element(line: str) -> Element:
|
|
230
|
+
tokens = line.split()
|
|
231
|
+
ref = tokens[0]
|
|
232
|
+
kind = ref[0].upper()
|
|
233
|
+
if kind == "X":
|
|
234
|
+
end = len(tokens)
|
|
235
|
+
while end > 2 and "=" in tokens[end - 1]:
|
|
236
|
+
end -= 1
|
|
237
|
+
# the subcircuit name is the last token before any NAME=value parameters
|
|
238
|
+
return Element(ref, tokens[1 : end - 1], " ".join(tokens[end - 1 :]))
|
|
239
|
+
count = _NODES.get(kind, 2)
|
|
240
|
+
return Element(ref, tokens[1 : 1 + count], " ".join(tokens[1 + count :]))
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
# ------------------------------------------------------------------ KiCad import
|
|
244
|
+
_NET_BAD = re.compile(r"[^A-Za-z0-9_]")
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def spice_node(net: str, ground: set[str]) -> str:
|
|
248
|
+
"""Legal SPICE node name for a schematic net name (``+3V3`` -> ``P3V3``, GND -> ``0``)."""
|
|
249
|
+
name = net.strip().lstrip("/")
|
|
250
|
+
if name.upper() in ground:
|
|
251
|
+
return "0"
|
|
252
|
+
if name.startswith("+"):
|
|
253
|
+
name = "P" + name[1:]
|
|
254
|
+
elif name.startswith("-"):
|
|
255
|
+
name = "N" + name[1:]
|
|
256
|
+
name = _NET_BAD.sub("_", name)
|
|
257
|
+
return name if name and not name[0].isdigit() else "n" + name
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
_PIN_ROLES = {
|
|
261
|
+
"D": {"A": "a", "K": "k", "1": "k", "2": "a"},
|
|
262
|
+
"OPAMP": {"+": "p", "-": "n", "~": "o", "OUT": "o", "1": "o", "2": "n", "3": "p"},
|
|
263
|
+
"LDO": {"VI": "i", "IN": "i", "VIN": "i", "VO": "o", "OUT": "o", "VOUT": "o", "GND": "g"},
|
|
264
|
+
"M": {"D": "d", "G": "g", "S": "s"},
|
|
265
|
+
"Q": {"C": "c", "B": "b", "E": "e"},
|
|
266
|
+
}
|
|
267
|
+
_OPAMP_NAMES = re.compile(
|
|
268
|
+
r"opamp|op_amp|lm358|lm324|lm741|tl07|tl08|mcp60|opa\d|ne5532|ad8\d\d|lt\d{4}", re.I
|
|
269
|
+
)
|
|
270
|
+
_LDO_NAMES = re.compile(
|
|
271
|
+
r"ams1117|lm1117|ld1117|ap2112|ap7\d|mcp1700|xc6206|lm78|l78|ld3985|ncp1117", re.I
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def circuit_from_netlist_text(
|
|
276
|
+
netlist: str,
|
|
277
|
+
symbols: dict[str, dict],
|
|
278
|
+
*,
|
|
279
|
+
title: str = "",
|
|
280
|
+
ground: set[str] | None = None,
|
|
281
|
+
sources: list[dict] | None = None,
|
|
282
|
+
base: Path | None = None,
|
|
283
|
+
) -> Circuit:
|
|
284
|
+
"""Build a Circuit from KiCad's ``kicadsexpr`` netlist plus schematic symbol fields.
|
|
285
|
+
|
|
286
|
+
``symbols`` maps reference -> {"value", "lib_id", "fields", "dnp", "exclude"}.
|
|
287
|
+
"""
|
|
288
|
+
ground = {g.upper() for g in (ground or set())} | GROUND_NAMES
|
|
289
|
+
root = sexpr.parse(netlist)
|
|
290
|
+
pins: dict[str, dict[str, str]] = {} # ref -> {pin number / function -> node}
|
|
291
|
+
nets_node = sexpr.find(root, "nets")
|
|
292
|
+
circuit = Circuit(title=title or "KiCad schematic")
|
|
293
|
+
for net in sexpr.find_all(nets_node, "net") if nets_node else []:
|
|
294
|
+
name = str(sexpr.value(net, "name", ""))
|
|
295
|
+
node = spice_node(name, ground)
|
|
296
|
+
circuit.node_map[name] = node
|
|
297
|
+
if name.startswith("/"):
|
|
298
|
+
circuit.node_map[name.lstrip("/")] = node
|
|
299
|
+
for n in sexpr.find_all(net, "node"):
|
|
300
|
+
ref = str(sexpr.value(n, "ref", ""))
|
|
301
|
+
pin = str(sexpr.value(n, "pin", ""))
|
|
302
|
+
func = pin_function(sexpr.value(n, "pinfunction", ""), pin).upper()
|
|
303
|
+
table = pins.setdefault(ref, {})
|
|
304
|
+
table["#" + pin] = node
|
|
305
|
+
if func:
|
|
306
|
+
table.setdefault(func, node)
|
|
307
|
+
comps = {
|
|
308
|
+
str(sexpr.value(c, "ref", "")): c
|
|
309
|
+
for c in sexpr.find_all(sexpr.find(root, "components"), "comp")
|
|
310
|
+
}
|
|
311
|
+
for ref in sorted(comps, key=lambda r: (r.rstrip("0123456789"), len(r), r)):
|
|
312
|
+
info = symbols.get(ref, {})
|
|
313
|
+
lib = str(sexpr.value(sexpr.find(comps[ref], "libsource"), "part", ""))
|
|
314
|
+
_add_part(circuit, ref, info, lib, pins.get(ref, {}), base)
|
|
315
|
+
for i, src in enumerate(sources or [], 1):
|
|
316
|
+
circuit.elements.append(_source_element(src, i, circuit, ground))
|
|
317
|
+
return circuit
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _source_element(src: dict, index: int, circuit: Circuit, ground: set[str]) -> Element:
|
|
321
|
+
name = str(src.get("name") or f"Vsrc{index}")
|
|
322
|
+
kind = str(src.get("kind") or "V").upper()
|
|
323
|
+
pos = circuit.node_map.get(src["pos"]) or spice_node(src["pos"], ground)
|
|
324
|
+
neg = circuit.node_map.get(src.get("neg", "GND")) or spice_node(src.get("neg", "GND"), ground)
|
|
325
|
+
spec = src.get("spec")
|
|
326
|
+
if not spec:
|
|
327
|
+
spec = f"DC {src.get('dc', 0)}"
|
|
328
|
+
if "ac" in src:
|
|
329
|
+
spec += f" AC {src['ac']}"
|
|
330
|
+
ref = name if name[:1].upper() == kind else kind + name
|
|
331
|
+
return Element(ref, [pos, neg], str(spec), "analysis source from the simulation spec")
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _sim_params(text: str) -> dict[str, str]:
|
|
335
|
+
return {k.lower(): v for k, v in re.findall(r"([A-Za-z_]\w*)\s*=\s*(\S+)", text or "")}
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _source_spec(fields: dict[str, str], value: str) -> str:
|
|
339
|
+
kind = fields.get("Sim.Type", "").upper()
|
|
340
|
+
p = _sim_params(fields.get("Sim.Params", ""))
|
|
341
|
+
ac = f" AC {p['ac']}" if "ac" in p else ""
|
|
342
|
+
if kind == "SIN":
|
|
343
|
+
args = [p.get(k, d) for k, d in (("dc", "0"), ("ampl", "1"), ("f", "1k"), ("td", "0"))]
|
|
344
|
+
return f"SIN({' '.join(args)}){ac}"
|
|
345
|
+
if kind == "PULSE":
|
|
346
|
+
keys = ("y1", "y2", "td", "tr", "tf", "tw", "per")
|
|
347
|
+
defaults = ("0", "1", "0", "1n", "1n", "0.5m", "1m")
|
|
348
|
+
return f"PULSE({' '.join(p.get(k, d) for k, d in zip(keys, defaults, strict=True))}){ac}"
|
|
349
|
+
if p.get("dc") or kind == "DC":
|
|
350
|
+
return f"DC {p.get('dc', value or 0)}{ac}"
|
|
351
|
+
return (
|
|
352
|
+
value if re.match(r"(?i)\s*(dc|ac|sin|pulse|pwl|exp)", value or "") else f"DC {value or 0}"
|
|
353
|
+
) + ac
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _add_part(
|
|
357
|
+
circuit: Circuit, ref: str, info: dict, lib: str, pins: dict[str, str], base: Path | None
|
|
358
|
+
) -> None:
|
|
359
|
+
fields: dict[str, str] = info.get("fields", {})
|
|
360
|
+
value = str(info.get("value", ""))
|
|
361
|
+
prefix = ref.rstrip("0123456789")[:1].upper()
|
|
362
|
+
if info.get("dnp") or info.get("exclude") or fields.get("Sim.Enable", "1") == "0":
|
|
363
|
+
circuit.skipped.append(f"{ref}: not fitted or excluded from simulation")
|
|
364
|
+
return
|
|
365
|
+
|
|
366
|
+
def pin(*names: str) -> str | None:
|
|
367
|
+
for n in names:
|
|
368
|
+
key = n if n.startswith("#") else n.upper()
|
|
369
|
+
if key in pins:
|
|
370
|
+
return pins[key]
|
|
371
|
+
return None
|
|
372
|
+
|
|
373
|
+
def two(first: str = "#1", second: str = "#2") -> list[str] | None:
|
|
374
|
+
a, b = pin(first), pin(second)
|
|
375
|
+
return [a, b] if a and b else None
|
|
376
|
+
|
|
377
|
+
library, subckt = fields.get("Sim.Library", ""), fields.get("Sim.Name", "")
|
|
378
|
+
if library and subckt and fields.get("Sim.Device", "").upper() in {"SUBCKT", "SPICE", ""}:
|
|
379
|
+
lib_path = Path(library)
|
|
380
|
+
if base and not lib_path.is_absolute():
|
|
381
|
+
lib_path = (base / lib_path).resolve()
|
|
382
|
+
line = f'.include "{lib_path}"'
|
|
383
|
+
if line not in circuit.includes:
|
|
384
|
+
circuit.includes.append(line)
|
|
385
|
+
mapping = fields.get("Sim.Pins", "")
|
|
386
|
+
order = sorted(
|
|
387
|
+
(m.groups() for m in re.finditer(r"(\w+)=(\w+)", mapping)),
|
|
388
|
+
key=lambda g: (int(g[1]) if g[1].isdigit() else 999, g[1]),
|
|
389
|
+
)
|
|
390
|
+
nodes = (
|
|
391
|
+
[pins.get("#" + a) for a, _ in order]
|
|
392
|
+
if order
|
|
393
|
+
else [
|
|
394
|
+
pins[k]
|
|
395
|
+
for k in sorted((k for k in pins if k.startswith("#")), key=lambda k: (len(k), k))
|
|
396
|
+
]
|
|
397
|
+
)
|
|
398
|
+
if None in nodes:
|
|
399
|
+
circuit.warnings.append(f"{ref}: Sim.Pins names a pin that is not connected")
|
|
400
|
+
return
|
|
401
|
+
extra = " ".join(f"{k}={v}" for k, v in _sim_params(fields.get("Sim.Params", "")).items())
|
|
402
|
+
circuit.elements.append(Element("X" + ref.lstrip("X"), nodes, f"{subckt} {extra}".strip()))
|
|
403
|
+
return
|
|
404
|
+
if prefix in {"R", "C", "L"}:
|
|
405
|
+
nodes = two()
|
|
406
|
+
if nodes:
|
|
407
|
+
params = fields.get("Sim.Params", "")
|
|
408
|
+
val = _sim_params(params).get(prefix.lower()) or value or "0"
|
|
409
|
+
circuit.elements.append(Element(ref, nodes, val.replace("µ", "u")))
|
|
410
|
+
return
|
|
411
|
+
if prefix == "F": # fuse / polyfuse
|
|
412
|
+
nodes = two()
|
|
413
|
+
if nodes:
|
|
414
|
+
circuit.elements.append(Element("R" + ref, nodes, "0.1", "generic fuse resistance"))
|
|
415
|
+
circuit.generic.append(f"{ref}: fuse as 0.1 ohm resistor (generic)")
|
|
416
|
+
return
|
|
417
|
+
if prefix in {"V", "I"} and fields.get("Sim.Device", "") != "":
|
|
418
|
+
nodes = [pin("+", "#1"), pin("-", "#2")]
|
|
419
|
+
if all(nodes):
|
|
420
|
+
circuit.elements.append(Element(ref, nodes, _source_spec(fields, value)))
|
|
421
|
+
return
|
|
422
|
+
if prefix in {"V", "I"} and re.match(r"(?i)\s*(dc|ac|sin|pulse|pwl|[-+\d.])", value or ""):
|
|
423
|
+
nodes = [pin("+", "#1"), pin("-", "#2")]
|
|
424
|
+
if all(nodes):
|
|
425
|
+
circuit.elements.append(Element(ref, nodes, _source_spec(fields, value)))
|
|
426
|
+
return
|
|
427
|
+
if prefix == "D":
|
|
428
|
+
a, k = pin("A", "#2"), pin("K", "#1")
|
|
429
|
+
if a and k:
|
|
430
|
+
is_led = "led" in lib.lower() or "led" in str(info.get("lib_id", "")).lower()
|
|
431
|
+
if is_led:
|
|
432
|
+
color = models.led_color(value, lib, fields.get("Color", ""))
|
|
433
|
+
name, text, vf = models.led_model(color)
|
|
434
|
+
circuit.blocks[name] = text
|
|
435
|
+
circuit.generic.append(
|
|
436
|
+
f"{ref}: generic {color.lower()} LED model, Vf about {vf:g} V at 2 mA (not the real part)"
|
|
437
|
+
)
|
|
438
|
+
else:
|
|
439
|
+
low = (value + " " + lib).lower()
|
|
440
|
+
name = (
|
|
441
|
+
"D_ZENER"
|
|
442
|
+
if "zener" in low or re.search(r"bzx|\bz\d", low)
|
|
443
|
+
else "D_SCHOTTKY"
|
|
444
|
+
if "schottky" in low or re.search(r"bat|ss\d|1n58|b5819", low)
|
|
445
|
+
else "D_RECTIFIER"
|
|
446
|
+
if re.search(r"1n400|rectif|s1[a-m]\b|m7", low)
|
|
447
|
+
else "D_SIGNAL"
|
|
448
|
+
)
|
|
449
|
+
circuit.blocks[name] = models.DIODES[name]
|
|
450
|
+
circuit.generic.append(
|
|
451
|
+
f"{ref}: generic {name[2:].lower()} diode model (not {value})"
|
|
452
|
+
)
|
|
453
|
+
circuit.elements.append(Element(ref, [a, k], name))
|
|
454
|
+
return
|
|
455
|
+
if prefix == "Q" and re.search(r"mos|fet", (lib + value).lower()):
|
|
456
|
+
d, g, s = pin("D"), pin("G"), pin("S")
|
|
457
|
+
if d and g and s:
|
|
458
|
+
name = (
|
|
459
|
+
"PMOS_GENERIC"
|
|
460
|
+
if re.search(r"pmos|p_?ch", (lib + value).lower())
|
|
461
|
+
else "NMOS_GENERIC"
|
|
462
|
+
)
|
|
463
|
+
circuit.blocks[name] = models.MOS[name]
|
|
464
|
+
circuit.elements.append(Element("M" + ref, [d, g, s, s], name + " W=1m L=1u"))
|
|
465
|
+
circuit.generic.append(f"{ref}: generic level-1 {name[:4]} model (not {value})")
|
|
466
|
+
return
|
|
467
|
+
if prefix == "Q":
|
|
468
|
+
c, b, e = pin("C"), pin("B"), pin("E")
|
|
469
|
+
if c and b and e:
|
|
470
|
+
name = "PNP_GENERIC" if re.search(r"pnp", (lib + value).lower()) else "NPN_GENERIC"
|
|
471
|
+
circuit.blocks[name] = models.BJT[name]
|
|
472
|
+
circuit.elements.append(Element(ref, [c, b, e], name))
|
|
473
|
+
circuit.generic.append(f"{ref}: generic {name[:3]} model (not {value})")
|
|
474
|
+
return
|
|
475
|
+
if prefix == "U" and _LDO_NAMES.search(value + lib):
|
|
476
|
+
i, o, g = pin("VI", "IN", "VIN"), pin("VO", "OUT", "VOUT"), pin("GND")
|
|
477
|
+
if i and o and g:
|
|
478
|
+
m = re.search(r"(\d+(?:\.\d+)?)\s*$", re.sub(r"(?i)[-_ ]?(sot|to|\d+mm).*", "", value))
|
|
479
|
+
vset = m.group(1) if m else fields.get("Sim.Params", "vset=3.3").split("=")[-1]
|
|
480
|
+
circuit.blocks["LDO_GENERIC"] = models.LDO
|
|
481
|
+
circuit.elements.append(Element("X" + ref, [i, o, g], f"LDO_GENERIC vset={vset}"))
|
|
482
|
+
circuit.generic.append(f"{ref}: generic LDO, {vset} V out, 1 V dropout (not {value})")
|
|
483
|
+
else:
|
|
484
|
+
circuit.warnings.append(f"{ref}: regulator pins not recognised; give Sim.Pins")
|
|
485
|
+
return
|
|
486
|
+
if prefix == "U" and (
|
|
487
|
+
_OPAMP_NAMES.search(value + lib) or fields.get("Sim.Name") == "OPAMP_IDEAL"
|
|
488
|
+
):
|
|
489
|
+
p, n, o = pin("+"), pin("-"), pin("~", "OUT")
|
|
490
|
+
if p and n and o:
|
|
491
|
+
circuit.blocks["OPAMP_IDEAL"] = models.OPAMP
|
|
492
|
+
circuit.elements.append(Element("X" + ref, [p, n, o], "OPAMP_IDEAL"))
|
|
493
|
+
circuit.generic.append(
|
|
494
|
+
f"{ref}: generic op-amp (gain 1e6, GBW 10 MHz, ideal output; not {value})"
|
|
495
|
+
)
|
|
496
|
+
else:
|
|
497
|
+
circuit.warnings.append(f"{ref}: multi-unit or unrecognised op-amp pins; give Sim.Pins")
|
|
498
|
+
return
|
|
499
|
+
circuit.skipped.append(f"{ref} ({value or lib}): no simulation model")
|
traceforge/sim/cli.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""``traceforge sim run|tune|plot``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from traceforge.sim.circuit import SimError
|
|
10
|
+
from traceforge.sim.engine import parse_analysis
|
|
11
|
+
from traceforge.sim.project import export_ltspice, load_circuit, replot, simulate
|
|
12
|
+
from traceforge.sim.tune import tune
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def add_parser(sub) -> None:
|
|
16
|
+
sim = sub.add_parser("sim", help="simulate with ngspice: run, tune a value, plot a rawfile")
|
|
17
|
+
actions = sim.add_subparsers(dest="sim_action", required=True)
|
|
18
|
+
|
|
19
|
+
def circuit_flags(p: argparse.ArgumentParser) -> None:
|
|
20
|
+
p.add_argument("file", help="KiCad .kicad_sch or a SPICE deck (.cir/.sp/.net)")
|
|
21
|
+
p.add_argument(
|
|
22
|
+
"--source",
|
|
23
|
+
action="append",
|
|
24
|
+
default=[],
|
|
25
|
+
metavar="NET=VOLTS",
|
|
26
|
+
help="DC source from NET to GND (repeatable); NET=VOLTS:AC adds an AC magnitude",
|
|
27
|
+
)
|
|
28
|
+
p.add_argument(
|
|
29
|
+
"--ground", action="append", default=[], help="extra net name to treat as ground"
|
|
30
|
+
)
|
|
31
|
+
p.add_argument(
|
|
32
|
+
"--set", action="append", default=[], metavar="REF=VALUE", help="override a value"
|
|
33
|
+
)
|
|
34
|
+
p.add_argument("--ac", metavar="'dec 20 10 100k'")
|
|
35
|
+
p.add_argument("--tran", metavar="'1u 5m'")
|
|
36
|
+
p.add_argument("--dc", metavar="'V1 0 5 0.1'")
|
|
37
|
+
p.add_argument("--noise", metavar="'v(out) V1 dec 10 10 100k'")
|
|
38
|
+
p.add_argument("--op", action="store_true")
|
|
39
|
+
p.add_argument("--meas", action="append", default=[], help="a .meas line (repeatable)")
|
|
40
|
+
p.add_argument("--out", default=".traceforge/sim")
|
|
41
|
+
p.add_argument("--json", action="store_true")
|
|
42
|
+
|
|
43
|
+
run = actions.add_parser("run", help="run one analysis and print the results")
|
|
44
|
+
circuit_flags(run)
|
|
45
|
+
run.add_argument("--probe", action="append", default=[], help="v(out), i(R5) ... (repeatable)")
|
|
46
|
+
run.add_argument("--export", nargs="*", default=[], choices=["csv", "vcd", "svg"])
|
|
47
|
+
run.add_argument("--loop-gain", help="AC signal that is the loop gain (phase/gain margin)")
|
|
48
|
+
run.add_argument("--pz", help="IN,OUT nodes for an ngspice pole-zero analysis")
|
|
49
|
+
run.add_argument("--engine", choices=["ngspice", "ltspice"], default="ngspice")
|
|
50
|
+
|
|
51
|
+
tun = actions.add_parser("tune", help="change a value until a measurement meets a target")
|
|
52
|
+
circuit_flags(tun)
|
|
53
|
+
tun.add_argument("--param", required=True, help="R1, a tied group R1,R2, or param:NAME")
|
|
54
|
+
tun.add_argument("--target", required=True, help="e.g. 'f3db(v(out))=500'")
|
|
55
|
+
tun.add_argument("--tol", type=float, default=0.01, help="relative tolerance (default 0.01)")
|
|
56
|
+
tun.add_argument("--bounds", help="LOW,HIGH for the parameter")
|
|
57
|
+
|
|
58
|
+
plot = actions.add_parser("plot", help="plot or export a rawfile")
|
|
59
|
+
plot.add_argument("raw")
|
|
60
|
+
plot.add_argument("--signal", action="append", default=[])
|
|
61
|
+
plot.add_argument("--formats", nargs="*", default=["svg"], choices=["svg", "csv", "vcd"])
|
|
62
|
+
plot.add_argument("--out", default=".traceforge/sim")
|
|
63
|
+
plot.add_argument("--json", action="store_true")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _analysis(args) -> object:
|
|
67
|
+
for kind in ("ac", "tran", "dc", "noise"):
|
|
68
|
+
text = getattr(args, kind, None)
|
|
69
|
+
if text:
|
|
70
|
+
return parse_analysis(f".{kind} {text}")
|
|
71
|
+
if args.op:
|
|
72
|
+
return parse_analysis(".op")
|
|
73
|
+
raise SimError("choose an analysis: --ac, --tran, --dc, --noise or --op")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _sources(items: list[str]) -> list[dict]:
|
|
77
|
+
out = []
|
|
78
|
+
for item in items:
|
|
79
|
+
net, _, rest = item.partition("=")
|
|
80
|
+
dc, _, ac = rest.partition(":")
|
|
81
|
+
src = {"pos": net, "dc": dc or "0"}
|
|
82
|
+
if ac:
|
|
83
|
+
src["ac"] = ac
|
|
84
|
+
out.append(src)
|
|
85
|
+
return out
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _circuit(args):
|
|
89
|
+
circuit = load_circuit(args.file, args.out, sources=_sources(args.source), ground=args.ground)
|
|
90
|
+
for item in args.set:
|
|
91
|
+
ref, _, value = item.partition("=")
|
|
92
|
+
circuit.set_value(ref, value)
|
|
93
|
+
return circuit
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def cmd_sim(args) -> int:
|
|
97
|
+
try:
|
|
98
|
+
if args.sim_action == "plot":
|
|
99
|
+
data = replot(args.raw, args.out, args.signal or None, args.formats)
|
|
100
|
+
else:
|
|
101
|
+
circuit = _circuit(args)
|
|
102
|
+
analysis = _analysis(args)
|
|
103
|
+
if args.sim_action == "run":
|
|
104
|
+
if args.pz:
|
|
105
|
+
analysis.pz = tuple(args.pz.split(",", 1))
|
|
106
|
+
if args.engine == "ltspice":
|
|
107
|
+
data = export_ltspice(circuit, [analysis], args.out, Path(args.file).stem)
|
|
108
|
+
else:
|
|
109
|
+
data = simulate(
|
|
110
|
+
circuit,
|
|
111
|
+
analysis,
|
|
112
|
+
args.out,
|
|
113
|
+
meas=args.meas,
|
|
114
|
+
probes=args.probe,
|
|
115
|
+
export=args.export,
|
|
116
|
+
loop=args.loop_gain,
|
|
117
|
+
)
|
|
118
|
+
else:
|
|
119
|
+
bounds = tuple(float(v) for v in args.bounds.split(",")) if args.bounds else None
|
|
120
|
+
result = tune(
|
|
121
|
+
circuit,
|
|
122
|
+
args.param,
|
|
123
|
+
args.target,
|
|
124
|
+
args.tol,
|
|
125
|
+
analysis=analysis,
|
|
126
|
+
bounds=bounds,
|
|
127
|
+
workdir=args.out,
|
|
128
|
+
meas=args.meas or None,
|
|
129
|
+
)
|
|
130
|
+
data = result.to_dict()
|
|
131
|
+
except (SimError, ValueError, KeyError, OSError) as exc:
|
|
132
|
+
print(f"sim: {exc}")
|
|
133
|
+
return 1
|
|
134
|
+
if args.json:
|
|
135
|
+
print(json.dumps(data, indent=2, default=str))
|
|
136
|
+
return 0
|
|
137
|
+
_print(data)
|
|
138
|
+
return 0 if data.get("converged", True) else 2
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _print(data: dict) -> None:
|
|
142
|
+
if "iterations" in data:
|
|
143
|
+
print(f"tune {data['parameter']}: {data['quantity']} -> {data['target']:g}")
|
|
144
|
+
for it in data["iterations"]:
|
|
145
|
+
print(
|
|
146
|
+
f" {it['n']:>2} value {it['value']:<12.6g} measured {it['measured']:<12.6g} error {it['error_pct']:+.3f} %"
|
|
147
|
+
)
|
|
148
|
+
verdict = "met" if data["converged"] else "NOT met"
|
|
149
|
+
print(
|
|
150
|
+
f"target {verdict}: {data['final_value']:.6g} gives {data['measured']:.6g} ({data['reason']})"
|
|
151
|
+
)
|
|
152
|
+
return
|
|
153
|
+
if "summary" in data:
|
|
154
|
+
print(data["summary"])
|
|
155
|
+
for key in ("bandwidth", "poles", "second_order", "margins", "operating_point"):
|
|
156
|
+
if key in data:
|
|
157
|
+
print(f"{key}: {json.dumps(data[key], default=str)}")
|
|
158
|
+
for key, value in (data.get("files") or {}).items():
|
|
159
|
+
print(f"{key}: {value}")
|
|
160
|
+
for key in ("netlist", "schematic", "note"):
|
|
161
|
+
if key in data:
|
|
162
|
+
print(f"{key}: {data[key]}")
|