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
traceforge/sim/engine.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Run ngspice in batch mode and collect the rawfile, ``.meas`` values and pole/zero output."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from traceforge.sim.circuit import Circuit, SimError
|
|
13
|
+
from traceforge.sim.rawfile import Plot, read_raw
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def find_ngspice() -> str | None:
|
|
17
|
+
return os.environ.get("TRACEFORGE_NGSPICE") or shutil.which("ngspice")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class Analysis:
|
|
22
|
+
kind: str # op | dc | ac | tran | noise
|
|
23
|
+
line: str # the SPICE directive
|
|
24
|
+
pz: tuple[str, str] | None = None # (input node, output node) for a .pz estimate
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
_KINDS = {"op", "dc", "ac", "tran", "noise"}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def parse_analysis(spec: object) -> Analysis:
|
|
31
|
+
"""Accept an Analysis, a directive (``.ac dec 20 10 100k`` or ``ac dec 20 10 100k``) or a dict
|
|
32
|
+
such as ``{"type": "tran", "step": "1u", "stop": "5m"}``."""
|
|
33
|
+
if isinstance(spec, Analysis):
|
|
34
|
+
return spec
|
|
35
|
+
if isinstance(spec, str):
|
|
36
|
+
text = spec.strip()
|
|
37
|
+
text = text if text.startswith(".") else "." + text
|
|
38
|
+
kind = text[1:].split()[0].lower() if text[1:].split() else ""
|
|
39
|
+
if kind not in _KINDS:
|
|
40
|
+
raise SimError(f"unknown analysis {spec!r}; use op, dc, ac, tran or noise")
|
|
41
|
+
return Analysis(kind, text)
|
|
42
|
+
if isinstance(spec, dict):
|
|
43
|
+
d = {k.lower(): v for k, v in spec.items()}
|
|
44
|
+
kind = str(d.get("type") or d.get("kind") or "").lower()
|
|
45
|
+
pz = (str(d["pz"][0]), str(d["pz"][1])) if d.get("pz") else None
|
|
46
|
+
if kind == "op":
|
|
47
|
+
return Analysis("op", ".op", pz)
|
|
48
|
+
if kind == "dc":
|
|
49
|
+
line = f".dc {d['source']} {d['start']} {d['stop']} {d['step']}"
|
|
50
|
+
elif kind == "ac":
|
|
51
|
+
line = f".ac {d.get('sweep', 'dec')} {d.get('points', 20)} {d['start']} {d['stop']}"
|
|
52
|
+
elif kind == "tran":
|
|
53
|
+
line = f".tran {d['step']} {d['stop']}"
|
|
54
|
+
if d.get("tstart") or d.get("max_step"):
|
|
55
|
+
line += f" {d.get('tstart', 0)}"
|
|
56
|
+
line += f" {d['max_step']}" if d.get("max_step") else ""
|
|
57
|
+
if d.get("uic"):
|
|
58
|
+
line += " uic"
|
|
59
|
+
elif kind == "noise":
|
|
60
|
+
line = (
|
|
61
|
+
f".noise {d['output']} {d['source']} {d.get('sweep', 'dec')} "
|
|
62
|
+
f"{d.get('points', 10)} {d['start']} {d['stop']}"
|
|
63
|
+
)
|
|
64
|
+
else:
|
|
65
|
+
raise SimError("analysis 'type' must be op, dc, ac, tran or noise")
|
|
66
|
+
return Analysis(kind, line, pz)
|
|
67
|
+
raise SimError(f"cannot read analysis {spec!r}")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass
|
|
71
|
+
class SimRun:
|
|
72
|
+
deck: Path
|
|
73
|
+
log: Path
|
|
74
|
+
raw: Path | None
|
|
75
|
+
plots: list[Plot]
|
|
76
|
+
analysis: Analysis
|
|
77
|
+
measures: dict[str, float | None] = field(default_factory=dict)
|
|
78
|
+
poles: list[complex] = field(default_factory=list)
|
|
79
|
+
zeros: list[complex] = field(default_factory=list)
|
|
80
|
+
problems: list[str] = field(default_factory=list)
|
|
81
|
+
circuit: Circuit | None = None
|
|
82
|
+
version: str = ""
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def plot(self) -> Plot:
|
|
86
|
+
return self.plots[0]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
_MEAS = re.compile(r"^\s*([A-Za-z_]\w*)\s*=\s*([-+]?[0-9.]+(?:e[-+]?\d+)?|failed)\b", re.I)
|
|
90
|
+
_PZ = re.compile(r"^\s*(pole|zero)\(\d+\)\s*=\s*([-+0-9.eE]+),\s*([-+0-9.eE]+)")
|
|
91
|
+
_PROBLEM = re.compile(
|
|
92
|
+
r"^\s*(error|warning|fatal|.*singular matrix|.*timestep too small|.*no such vector|"
|
|
93
|
+
r".*could not find|.*unknown subckt|.*can't find|.*doesn't exist).*",
|
|
94
|
+
re.I,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def run_ngspice(
|
|
99
|
+
circuit: Circuit | str,
|
|
100
|
+
analysis: object,
|
|
101
|
+
workdir: str | Path,
|
|
102
|
+
*,
|
|
103
|
+
meas: list[str] | None = None,
|
|
104
|
+
save: list[str] | None = None,
|
|
105
|
+
timeout: float = 120,
|
|
106
|
+
name: str = "run",
|
|
107
|
+
) -> SimRun:
|
|
108
|
+
"""Run one analysis. ``meas`` are ``.meas`` lines; ``save`` extra vectors such as ``@r1[i]``."""
|
|
109
|
+
exe = find_ngspice()
|
|
110
|
+
if exe is None:
|
|
111
|
+
raise SimError(
|
|
112
|
+
"ngspice not found: install it (apt/brew/choco install ngspice) or set TRACEFORGE_NGSPICE"
|
|
113
|
+
)
|
|
114
|
+
work = Path(workdir)
|
|
115
|
+
work.mkdir(parents=True, exist_ok=True)
|
|
116
|
+
ana = parse_analysis(analysis)
|
|
117
|
+
circ = Circuit.from_text(circuit) if isinstance(circuit, str) else circuit
|
|
118
|
+
other = [
|
|
119
|
+
d
|
|
120
|
+
for d in circ.directives
|
|
121
|
+
if not d.lower().startswith((".ac", ".tran", ".dc", ".op", ".noise", ".pz", ".tf"))
|
|
122
|
+
]
|
|
123
|
+
saved = [f".save all {' '.join(save)}"] if save else []
|
|
124
|
+
extra = [ana.line, *(meas or []), *saved]
|
|
125
|
+
raw_name = f"{name}.raw"
|
|
126
|
+
control = ["set filetype=binary", "run"]
|
|
127
|
+
if ana.kind == "noise":
|
|
128
|
+
control.append("setplot noise1")
|
|
129
|
+
control.append(f"write {raw_name} all")
|
|
130
|
+
if ana.pz:
|
|
131
|
+
control += [f"pz {ana.pz[0]} 0 {ana.pz[1]} 0 vol pz", "print all"]
|
|
132
|
+
control.append("quit")
|
|
133
|
+
shadow = Circuit(**{**circ.__dict__, "directives": other})
|
|
134
|
+
deck = work / f"{name}.cir"
|
|
135
|
+
deck.write_text(shadow.to_spice(extra, control), encoding="utf-8")
|
|
136
|
+
log = work / f"{name}.log"
|
|
137
|
+
raw = work / raw_name
|
|
138
|
+
raw.unlink(missing_ok=True)
|
|
139
|
+
try:
|
|
140
|
+
done = subprocess.run( # noqa: S603 - fixed executable, list args
|
|
141
|
+
[exe, "-b", "-o", log.name, deck.name],
|
|
142
|
+
capture_output=True,
|
|
143
|
+
text=True,
|
|
144
|
+
timeout=timeout,
|
|
145
|
+
cwd=work,
|
|
146
|
+
check=False,
|
|
147
|
+
)
|
|
148
|
+
except subprocess.TimeoutExpired as exc:
|
|
149
|
+
raise SimError(f"ngspice timed out after {timeout:g} s") from exc
|
|
150
|
+
except OSError as exc:
|
|
151
|
+
raise SimError(f"ngspice failed to start: {exc}") from exc
|
|
152
|
+
text = (log.read_text(encoding="utf-8", errors="replace") if log.exists() else "") + done.stdout
|
|
153
|
+
problems = [m.group(0).strip() for m in map(_PROBLEM.match, text.splitlines()) if m]
|
|
154
|
+
if not raw.exists() or raw.stat().st_size == 0:
|
|
155
|
+
detail = "; ".join(problems[:4]) or text.strip()[-400:]
|
|
156
|
+
raise SimError(f"ngspice produced no results: {detail}")
|
|
157
|
+
all_meas = [*(meas or []), *(d for d in circ.directives if d.lower().startswith(".meas"))]
|
|
158
|
+
wanted = {m.split()[2] for m in all_meas if len(m.split()) > 2}
|
|
159
|
+
measures: dict[str, float | None] = {}
|
|
160
|
+
poles: list[complex] = []
|
|
161
|
+
zeros: list[complex] = []
|
|
162
|
+
for line in text.splitlines():
|
|
163
|
+
if pz := _PZ.match(line):
|
|
164
|
+
(poles if pz.group(1) == "pole" else zeros).append(
|
|
165
|
+
complex(float(pz.group(2)), float(pz.group(3)))
|
|
166
|
+
)
|
|
167
|
+
elif (m := _MEAS.match(line)) and m.group(1).lower() in {w.lower() for w in wanted}:
|
|
168
|
+
measures[m.group(1).lower()] = None if m.group(2) == "failed" else float(m.group(2))
|
|
169
|
+
version = next((ln.strip("* ").strip() for ln in text.splitlines() if "ngspice-" in ln), "")
|
|
170
|
+
return SimRun(
|
|
171
|
+
deck, log, raw, read_raw(raw), ana, measures, poles, zeros, problems, circ, version
|
|
172
|
+
)
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""LTspice support: netlist and ``.asc`` generation, compatibility warnings, ``.raw`` reader.
|
|
2
|
+
|
|
3
|
+
LTspice does not run on Linux, so nothing here runs it. The files are written for LTspice
|
|
4
|
+
to open; the generated ``.asc`` has not been opened in LTspice by the author of this code.
|
|
5
|
+
Models that only LTspice understands cannot run in ngspice; ``compat_warnings`` lists the
|
|
6
|
+
usual markers.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
import struct
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from traceforge.sim.circuit import Circuit, SimError
|
|
16
|
+
from traceforge.sim.rawfile import Plot
|
|
17
|
+
|
|
18
|
+
# (pattern, what is wrong for the other simulator)
|
|
19
|
+
_LTSPICE_ONLY = [
|
|
20
|
+
(
|
|
21
|
+
r"\b(Rser|Lser|Cpar|Rpar|Rdamp|Ipk|Vpk)\s*=",
|
|
22
|
+
"LTspice-only component parameters (Rser, Cpar, ...)",
|
|
23
|
+
),
|
|
24
|
+
(r"\bmfg\s*=|\bVDMOS\b", "LTspice-only model syntax (mfg=, VDMOS)"),
|
|
25
|
+
(r"\.backanno|\.wave\b|\.savebias", "LTspice-only dot command"),
|
|
26
|
+
(r"\btable\s*\(", "LTspice table() function; ngspice uses a different table syntax"),
|
|
27
|
+
(
|
|
28
|
+
r"\bltc\w*\.(lib|sub)|\blib\b.*\\LTC\\|\bstandard\.(dio|bjt|mos|jft|cap|ind|res)\b",
|
|
29
|
+
"LTspice installation library; not available to ngspice",
|
|
30
|
+
),
|
|
31
|
+
]
|
|
32
|
+
# What ngspice (as used here) cannot read from an LTspice-flavoured deck.
|
|
33
|
+
_NGSPICE_ONLY = [(r"^\s*\.control\b", "ngspice .control block; LTspice ignores or rejects it")]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def compat_warnings(text: str, target: str = "ngspice") -> list[str]:
|
|
37
|
+
"""Markers of models the other simulator cannot run."""
|
|
38
|
+
rules = _LTSPICE_ONLY if target == "ngspice" else _NGSPICE_ONLY
|
|
39
|
+
return [msg for pat, msg in rules if re.search(pat, text, re.I | re.M)]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def write_netlist(circuit: Circuit, analysis: list[str], path: str | Path) -> Path:
|
|
43
|
+
"""A SPICE netlist LTspice can open (File > Open) and run. Generic models are included."""
|
|
44
|
+
text = circuit.to_spice(list(analysis))
|
|
45
|
+
path = Path(path)
|
|
46
|
+
path.write_text(text, encoding="utf-8")
|
|
47
|
+
return path
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# symbol name, pin offsets from the symbol origin (R0), node index order
|
|
51
|
+
_SYMBOLS = {
|
|
52
|
+
"R": ("res", [(16, 16), (16, 96)]),
|
|
53
|
+
"C": ("cap", [(16, 0), (16, 64)]),
|
|
54
|
+
"L": ("ind", [(16, 16), (16, 80)]),
|
|
55
|
+
"D": ("diode", [(16, 0), (16, 64)]), # anode, cathode (circuit order: anode first)
|
|
56
|
+
"V": ("voltage", [(0, 16), (0, 96)]),
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def write_asc(circuit: Circuit, analysis: list[str], path: str | Path) -> Path:
|
|
61
|
+
"""An LTspice schematic: R, C, L, D and V as symbols with net flags on every pin (no wires
|
|
62
|
+
needed), everything else as SPICE directives with the same node names."""
|
|
63
|
+
lines = ["Version 4", "SHEET 1 1400 1000"]
|
|
64
|
+
texts: list[str] = []
|
|
65
|
+
placed = 0
|
|
66
|
+
for el in circuit.elements:
|
|
67
|
+
sym = _SYMBOLS.get(el.kind)
|
|
68
|
+
if sym is None or len(el.nodes) != 2 or el.ref.upper().startswith("X"):
|
|
69
|
+
texts.append(el.line())
|
|
70
|
+
continue
|
|
71
|
+
name, pins = sym
|
|
72
|
+
x, y = 96 + (placed % 6) * 200, 96 + (placed // 6) * 200
|
|
73
|
+
placed += 1
|
|
74
|
+
lines.append(f"SYMBOL {name} {x} {y} R0")
|
|
75
|
+
lines.append(f"SYMATTR InstName {el.ref}")
|
|
76
|
+
lines.append(f"SYMATTR Value {el.value}")
|
|
77
|
+
for (dx, dy), node in zip(pins, el.nodes, strict=True):
|
|
78
|
+
lines.append(f"FLAG {x + dx} {y + dy} {node}")
|
|
79
|
+
ty = 96 + ((placed + 5) // 6) * 200 + 40
|
|
80
|
+
for block in circuit.blocks.values():
|
|
81
|
+
lines.append(f"TEXT 96 {ty} Left 2 !" + block.replace("\n", "\\n"))
|
|
82
|
+
ty += 40
|
|
83
|
+
for text in [*texts, *circuit.includes, *analysis]:
|
|
84
|
+
lines.append(f"TEXT 96 {ty} Left 2 !{text}")
|
|
85
|
+
ty += 40
|
|
86
|
+
path = Path(path)
|
|
87
|
+
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
88
|
+
return path
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def read_ltspice_raw(path: str | Path) -> Plot:
|
|
92
|
+
"""Read an LTspice binary ``.raw`` (UTF-16 or ASCII header, ``Binary:`` data).
|
|
93
|
+
|
|
94
|
+
Real analyses store the sweep variable as a double and the others as single floats;
|
|
95
|
+
complex (AC) analyses store every value as two doubles. The ``fastaccess`` layout and
|
|
96
|
+
stepped runs are not supported and raise a clear error.
|
|
97
|
+
"""
|
|
98
|
+
blob = Path(path).read_bytes()
|
|
99
|
+
wide = blob[:4] == b"T\x00i\x00"
|
|
100
|
+
marker = "Binary:\n".encode("utf-16-le" if wide else "latin-1")
|
|
101
|
+
cut = blob.find(marker)
|
|
102
|
+
if cut < 0:
|
|
103
|
+
raise SimError(f"{path}: no 'Binary:' section (ASCII LTspice raw files are not supported)")
|
|
104
|
+
header = blob[:cut].decode("utf-16-le" if wide else "latin-1")
|
|
105
|
+
body = blob[cut + len(marker) :]
|
|
106
|
+
fields: dict[str, str] = {}
|
|
107
|
+
names: list[str] = []
|
|
108
|
+
types: list[str] = []
|
|
109
|
+
in_vars = False
|
|
110
|
+
for line in header.splitlines():
|
|
111
|
+
if in_vars and line[:1] in " \t":
|
|
112
|
+
parts = line.split()
|
|
113
|
+
names.append(parts[1])
|
|
114
|
+
types.append(parts[2] if len(parts) > 2 else "")
|
|
115
|
+
continue
|
|
116
|
+
key, _, rest = line.partition(":")
|
|
117
|
+
in_vars = key.strip() == "Variables"
|
|
118
|
+
if not in_vars:
|
|
119
|
+
fields[key.strip()] = rest.strip()
|
|
120
|
+
flags = fields.get("Flags", "").lower()
|
|
121
|
+
if "fastaccess" in flags:
|
|
122
|
+
raise SimError("LTspice 'fastaccess' raw files are not supported; save without Fast Access")
|
|
123
|
+
n_vars, n_points = int(fields["No. Variables"]), int(fields["No. Points"])
|
|
124
|
+
if n_points and any(n == names[0] for n in names[1:]) and "stepped" in flags:
|
|
125
|
+
raise SimError("stepped LTspice runs are not supported")
|
|
126
|
+
cplx = "complex" in flags
|
|
127
|
+
plot = Plot(fields.get("Plotname", ""), fields.get("Title", ""), cplx, names, types)
|
|
128
|
+
cols: list[list] = [[] for _ in names]
|
|
129
|
+
off = 0
|
|
130
|
+
for _ in range(n_points):
|
|
131
|
+
for j in range(n_vars):
|
|
132
|
+
if cplx:
|
|
133
|
+
re_, im = struct.unpack_from("<dd", body, off)
|
|
134
|
+
off += 16
|
|
135
|
+
cols[j].append(complex(re_, im))
|
|
136
|
+
elif j == 0:
|
|
137
|
+
cols[j].append(abs(struct.unpack_from("<d", body, off)[0])) # negative = compressed
|
|
138
|
+
off += 8
|
|
139
|
+
else:
|
|
140
|
+
cols[j].append(struct.unpack_from("<f", body, off)[0])
|
|
141
|
+
off += 4
|
|
142
|
+
plot.data = {n.lower(): c for n, c in zip(names, cols, strict=True)}
|
|
143
|
+
return plot
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def write_ltspice_raw(plot: Plot, path: str | Path) -> Path:
|
|
147
|
+
"""Write ``plot`` in LTspice's layout. Used to test the reader on a synthetic file."""
|
|
148
|
+
n = len(plot.names)
|
|
149
|
+
head = [
|
|
150
|
+
f"Title: {plot.title}",
|
|
151
|
+
"Date: Thu Jan 01 00:00:00 2026",
|
|
152
|
+
f"Plotname: {plot.name}",
|
|
153
|
+
f"Flags: {'complex' if plot.complex else 'real'}",
|
|
154
|
+
f"No. Variables: {n}",
|
|
155
|
+
f"No. Points: {len(plot.x)}",
|
|
156
|
+
"Offset: 0.0000000000000000e+000",
|
|
157
|
+
"Command: synthetic",
|
|
158
|
+
"Variables:",
|
|
159
|
+
*[
|
|
160
|
+
f"\t{i}\t{nm}\t{tp}"
|
|
161
|
+
for i, (nm, tp) in enumerate(zip(plot.names, plot.types, strict=True))
|
|
162
|
+
],
|
|
163
|
+
"Binary:\n",
|
|
164
|
+
]
|
|
165
|
+
out = bytearray("\n".join(head).encode("utf-16-le"))
|
|
166
|
+
for k in range(len(plot.x)):
|
|
167
|
+
for j, nm in enumerate(plot.names):
|
|
168
|
+
v = plot.data[nm.lower()][k]
|
|
169
|
+
if plot.complex:
|
|
170
|
+
out += struct.pack("<dd", v.real, v.imag)
|
|
171
|
+
else:
|
|
172
|
+
out += struct.pack("<d", v) if j == 0 else struct.pack("<f", v)
|
|
173
|
+
path = Path(path)
|
|
174
|
+
path.write_bytes(bytes(out))
|
|
175
|
+
return path
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Named measurements on a result, e.g. ``f3db(v(out))`` or ``i(R5)``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import re
|
|
7
|
+
|
|
8
|
+
from traceforge.sim import acmath
|
|
9
|
+
from traceforge.sim.circuit import SimError, parse_value
|
|
10
|
+
from traceforge.sim.results import Result
|
|
11
|
+
|
|
12
|
+
FUNCTIONS = (
|
|
13
|
+
"f3db, dc_gain_db(v), gain_db(v, f), phase_deg(v, f), peak_db(v), f_peak(v), "
|
|
14
|
+
"unity_gain_freq(v), phase_margin(v), gain_margin(v), value(v), final(v), max(v), min(v), "
|
|
15
|
+
"pp(v), mean(v), rms(v), rise_time(v), meas(name), v(node), i(Rref)"
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _split(args: str) -> list[str]:
|
|
20
|
+
out, depth, cur = [], 0, ""
|
|
21
|
+
for ch in args:
|
|
22
|
+
depth += ch == "("
|
|
23
|
+
depth -= ch == ")"
|
|
24
|
+
if ch == "," and depth == 0:
|
|
25
|
+
out.append(cur.strip())
|
|
26
|
+
cur = ""
|
|
27
|
+
else:
|
|
28
|
+
cur += ch
|
|
29
|
+
return [*out, cur.strip()] if cur.strip() else out
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _real(values: list) -> list[float]:
|
|
33
|
+
return [v.real if isinstance(v, complex) else v for v in values]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _time_avg(t: list[float], y: list[float], square: bool = False) -> float:
|
|
37
|
+
total = 0.0
|
|
38
|
+
for i in range(1, len(t)):
|
|
39
|
+
a, b = (y[i - 1] ** 2, y[i] ** 2) if square else (y[i - 1], y[i])
|
|
40
|
+
total += (a + b) / 2 * (t[i] - t[i - 1])
|
|
41
|
+
span = t[-1] - t[0]
|
|
42
|
+
return total / span if span > 0 else y[-1]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def measure(result: Result, spec: str) -> float:
|
|
46
|
+
text = spec.strip()
|
|
47
|
+
m = re.fullmatch(r"(\w+)\((.*)\)", text)
|
|
48
|
+
if not m:
|
|
49
|
+
m = re.fullmatch(r"(\w+)", text)
|
|
50
|
+
if not m:
|
|
51
|
+
raise SimError(f"cannot read measurement {spec!r}; known: {FUNCTIONS}")
|
|
52
|
+
name, args = m.group(1).lower(), []
|
|
53
|
+
else:
|
|
54
|
+
name, args = m.group(1).lower(), _split(m.group(2))
|
|
55
|
+
if name in {"v", "i"}:
|
|
56
|
+
return float(_real([result.value(text)])[0])
|
|
57
|
+
if name == "meas":
|
|
58
|
+
run = result.run
|
|
59
|
+
value = run.measures.get(args[0].lower()) if run else None
|
|
60
|
+
if value is None:
|
|
61
|
+
raise SimError(f".meas {args[0]!r} did not produce a value")
|
|
62
|
+
return value
|
|
63
|
+
if not args and name == "f3db":
|
|
64
|
+
args = [result.table()[0]]
|
|
65
|
+
sig = result.signal(args[0])
|
|
66
|
+
x = result.x
|
|
67
|
+
if name in {"value", "final"}:
|
|
68
|
+
return float(_real([sig[-1]])[0])
|
|
69
|
+
if result.plot.complex:
|
|
70
|
+
return _ac(name, x, sig, args)
|
|
71
|
+
y = _real(sig)
|
|
72
|
+
if name == "max":
|
|
73
|
+
return max(y)
|
|
74
|
+
if name == "min":
|
|
75
|
+
return min(y)
|
|
76
|
+
if name == "pp":
|
|
77
|
+
return max(y) - min(y)
|
|
78
|
+
if name == "mean":
|
|
79
|
+
return _time_avg(x, y)
|
|
80
|
+
if name == "rms":
|
|
81
|
+
return math.sqrt(_time_avg(x, y, square=True))
|
|
82
|
+
if name == "rise_time":
|
|
83
|
+
lo, hi = min(y), max(y)
|
|
84
|
+
t10 = _first_cross(x, y, lo + 0.1 * (hi - lo))
|
|
85
|
+
t90 = _first_cross(x, y, lo + 0.9 * (hi - lo))
|
|
86
|
+
if t10 is None or t90 is None:
|
|
87
|
+
raise SimError("no rising edge to time")
|
|
88
|
+
return t90 - t10
|
|
89
|
+
raise SimError(f"{name!r} needs an AC result or is unknown; known: {FUNCTIONS}")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _first_cross(x: list[float], y: list[float], level: float) -> float | None:
|
|
93
|
+
for i in range(1, len(x)):
|
|
94
|
+
if y[i - 1] < level <= y[i]:
|
|
95
|
+
return x[i - 1] + (level - y[i - 1]) / (y[i] - y[i - 1]) * (x[i] - x[i - 1])
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _ac(name: str, f: list[float], h: list[complex], args: list[str]) -> float:
|
|
100
|
+
if name in {"f3db", "fc"}:
|
|
101
|
+
v = acmath.bandwidth(f, h)["f3db"]
|
|
102
|
+
elif name == "dc_gain_db":
|
|
103
|
+
return acmath.mag_db(h[0])
|
|
104
|
+
elif name == "gain_db":
|
|
105
|
+
return acmath.interp_log(f, [acmath.mag_db(v) for v in h], parse_value(args[1]))
|
|
106
|
+
elif name == "phase_deg":
|
|
107
|
+
return acmath.interp_log(f, acmath.phase_deg(h), parse_value(args[1]))
|
|
108
|
+
elif name in {"peak_db", "f_peak"}:
|
|
109
|
+
db = [acmath.mag_db(v) for v in h]
|
|
110
|
+
return max(db) if name == "peak_db" else f[db.index(max(db))]
|
|
111
|
+
else:
|
|
112
|
+
margins = acmath.stability_margins(f, h)
|
|
113
|
+
key = {
|
|
114
|
+
"unity_gain_freq": "unity_gain_freq",
|
|
115
|
+
"phase_margin": "phase_margin_deg",
|
|
116
|
+
"gain_margin": "gain_margin_db",
|
|
117
|
+
}.get(name)
|
|
118
|
+
if key is None:
|
|
119
|
+
raise SimError(f"unknown measurement {name!r}; known: {FUNCTIONS}")
|
|
120
|
+
v = margins[key]
|
|
121
|
+
if v is None:
|
|
122
|
+
raise SimError(f"{name} does not exist in this response (no crossing in the swept range)")
|
|
123
|
+
return float(v)
|
traceforge/sim/models.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Generic SPICE models used when a part has no vendor model.
|
|
2
|
+
|
|
3
|
+
Every model here is a *generic stand-in*, not the real part: an ideal-ish op-amp, a
|
|
4
|
+
behavioural linear regulator, a typical small-signal diode, an LED with a colour-typical
|
|
5
|
+
forward voltage, level-1 MOSFETs and simple BJTs. The netlist exporter records each one
|
|
6
|
+
in ``Circuit.generic`` so results can say so.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import math
|
|
12
|
+
import re
|
|
13
|
+
|
|
14
|
+
OPAMP = """.subckt OPAMP_IDEAL inp inn out PARAMS: gain=1e6 gbw=10meg
|
|
15
|
+
* GENERIC single-pole op-amp: open-loop gain, gain-bandwidth product, ideal output
|
|
16
|
+
Gm 0 p inp inn {gain/1k}
|
|
17
|
+
Rp p 0 1k
|
|
18
|
+
Cp p 0 {gain/(6.283185307*gbw*1k)}
|
|
19
|
+
Eo out 0 p 0 1
|
|
20
|
+
.ends OPAMP_IDEAL"""
|
|
21
|
+
|
|
22
|
+
LDO = """.subckt LDO_GENERIC vin vout gnd PARAMS: vset=3.3 vdo=1.0 rout=0.05 iq=5m
|
|
23
|
+
* GENERIC behavioural linear regulator: fixed output, dropout, output resistance, quiescent current
|
|
24
|
+
Bv x gnd V=max(0, min(V(vin,gnd)-{vdo}, {vset}))
|
|
25
|
+
Ro x vout {rout}
|
|
26
|
+
Iq vin gnd {iq}
|
|
27
|
+
.ends LDO_GENERIC"""
|
|
28
|
+
|
|
29
|
+
DIODES = {
|
|
30
|
+
"D_SIGNAL": ".model D_SIGNAL D(IS=2.5n N=1.75 RS=0.6 CJO=4p TT=5n BV=100 IBV=0.1u)",
|
|
31
|
+
"D_RECTIFIER": ".model D_RECTIFIER D(IS=5n N=1.7 RS=0.04 CJO=40p TT=5u BV=100 IBV=5u)",
|
|
32
|
+
"D_SCHOTTKY": ".model D_SCHOTTKY D(IS=1u N=1.05 RS=0.08 CJO=100p TT=1n BV=40 IBV=10u)",
|
|
33
|
+
"D_ZENER": ".model D_ZENER D(IS=5n N=1.7 RS=2 BV=5.1 IBV=5m)",
|
|
34
|
+
}
|
|
35
|
+
MOS = {
|
|
36
|
+
"NMOS_GENERIC": ".model NMOS_GENERIC NMOS(LEVEL=1 VTO=2 KP=0.2 LAMBDA=0.01 CBD=50p CGSO=1n)",
|
|
37
|
+
"PMOS_GENERIC": ".model PMOS_GENERIC PMOS(LEVEL=1 VTO=-2 KP=0.1 LAMBDA=0.01 CBD=50p CGSO=1n)",
|
|
38
|
+
}
|
|
39
|
+
BJT = {
|
|
40
|
+
"NPN_GENERIC": ".model NPN_GENERIC NPN(BF=200 IS=1e-14 VAF=100 CJE=10p CJC=5p TF=0.4n)",
|
|
41
|
+
"PNP_GENERIC": ".model PNP_GENERIC PNP(BF=150 IS=1e-14 VAF=60 CJE=10p CJC=5p TF=0.5n)",
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
# Typical forward voltage at 2 mA for common LED colours (volts).
|
|
45
|
+
LED_VF = {
|
|
46
|
+
"RED": 1.9,
|
|
47
|
+
"ORANGE": 2.0,
|
|
48
|
+
"AMBER": 2.0,
|
|
49
|
+
"YELLOW": 2.0,
|
|
50
|
+
"GREEN": 2.1,
|
|
51
|
+
"BLUE": 3.0,
|
|
52
|
+
"WHITE": 3.0,
|
|
53
|
+
"UV": 3.3,
|
|
54
|
+
"IR": 1.3,
|
|
55
|
+
}
|
|
56
|
+
_VT = 0.025852
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def led_model(color: str) -> tuple[str, str, float]:
|
|
60
|
+
"""(model name, .model line, Vf at 2 mA) for a colour name; unknown colours use red."""
|
|
61
|
+
color = color.upper()
|
|
62
|
+
vf = LED_VF.get(color)
|
|
63
|
+
key = color if vf else "RED"
|
|
64
|
+
vf = vf or LED_VF["RED"]
|
|
65
|
+
n = 2.0
|
|
66
|
+
isat = 2e-3 / math.exp(vf / (n * _VT))
|
|
67
|
+
name = f"LED_{key}"
|
|
68
|
+
return name, f".model {name} D(IS={isat:.3e} N={n} RS=4 CJO=30p BV=5 IBV=10u)", vf
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def led_color(*texts: str) -> str:
|
|
72
|
+
for text in texts:
|
|
73
|
+
for word in re.split(r"[^A-Za-z]+", text.upper()):
|
|
74
|
+
if word in LED_VF:
|
|
75
|
+
return word
|
|
76
|
+
return "RED"
|