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.
Files changed (219) hide show
  1. traceforge/__init__.py +3 -0
  2. traceforge/__main__.py +3 -0
  3. traceforge/acp_server.py +570 -0
  4. traceforge/agent.py +680 -0
  5. traceforge/altium/__init__.py +6 -0
  6. traceforge/altium/cfb.py +70 -0
  7. traceforge/altium/cli.py +60 -0
  8. traceforge/altium/model.py +163 -0
  9. traceforge/altium/outputs.py +24 -0
  10. traceforge/altium/pcb.py +301 -0
  11. traceforge/altium/project.py +328 -0
  12. traceforge/altium/schematic.py +400 -0
  13. traceforge/altium/tools.py +166 -0
  14. traceforge/approval.py +219 -0
  15. traceforge/attachments.py +346 -0
  16. traceforge/background.py +253 -0
  17. traceforge/bench/__init__.py +5 -0
  18. traceforge/bench/adapters.py +337 -0
  19. traceforge/bench/checks.py +410 -0
  20. traceforge/bench/cli.py +121 -0
  21. traceforge/bench/design.py +439 -0
  22. traceforge/bench/kicad.py +99 -0
  23. traceforge/bench/partscheck.py +159 -0
  24. traceforge/bench/review.py +100 -0
  25. traceforge/bench/runner.py +226 -0
  26. traceforge/bench/score.py +204 -0
  27. traceforge/bench/tasks.py +102 -0
  28. traceforge/chat.py +1185 -0
  29. traceforge/checks.py +328 -0
  30. traceforge/checks_sides.py +118 -0
  31. traceforge/cli.py +2224 -0
  32. traceforge/clipboard.py +107 -0
  33. traceforge/config.py +299 -0
  34. traceforge/context.py +276 -0
  35. traceforge/design/__init__.py +1 -0
  36. traceforge/design/constraints.py +397 -0
  37. traceforge/design/generate.py +1789 -0
  38. traceforge/design/outline.py +905 -0
  39. traceforge/design/schematic_layout.py +932 -0
  40. traceforge/design/schematic_lint.py +329 -0
  41. traceforge/design/schematic_route.py +207 -0
  42. traceforge/design/sheets.py +854 -0
  43. traceforge/design/sides.py +328 -0
  44. traceforge/design/spec.py +462 -0
  45. traceforge/design/specsheet.py +2351 -0
  46. traceforge/design/specsheet_render.py +183 -0
  47. traceforge/desktop/__init__.py +109 -0
  48. traceforge/desktop/keys.py +90 -0
  49. traceforge/desktop/project.py +443 -0
  50. traceforge/desktop/review.py +20 -0
  51. traceforge/desktop/server.py +1277 -0
  52. traceforge/desktop/static/app.css +223 -0
  53. traceforge/desktop/static/app.js +683 -0
  54. traceforge/desktop/static/highlight.js +77 -0
  55. traceforge/desktop/static/index.html +89 -0
  56. traceforge/desktop/static/md.js +90 -0
  57. traceforge/desktop/static/viewer.js +213 -0
  58. traceforge/desktop/symbols.py +212 -0
  59. traceforge/desktop/update.py +65 -0
  60. traceforge/diagnostics.py +67 -0
  61. traceforge/errata.py +219 -0
  62. traceforge/fab.py +108 -0
  63. traceforge/fab_layers.py +44 -0
  64. traceforge/features/__init__.py +6 -0
  65. traceforge/features/candidates.py +117 -0
  66. traceforge/features/cli.py +200 -0
  67. traceforge/features/package.py +433 -0
  68. traceforge/features/revdiff.py +224 -0
  69. traceforge/features/sarif.py +145 -0
  70. traceforge/features/viewer.py +265 -0
  71. traceforge/firmware/__init__.py +3 -0
  72. traceforge/firmware/cli.py +174 -0
  73. traceforge/firmware/drivers.py +372 -0
  74. traceforge/firmware/drivers_data.py +256 -0
  75. traceforge/firmware/mcus.py +177 -0
  76. traceforge/firmware/netlist.py +55 -0
  77. traceforge/firmware/pinmap.py +296 -0
  78. traceforge/firmware/scaffold.py +466 -0
  79. traceforge/firmware/toolchain.py +262 -0
  80. traceforge/hooks.py +237 -0
  81. traceforge/kicad/__init__.py +0 -0
  82. traceforge/kicad/cli.py +519 -0
  83. traceforge/kicad/diffpair.py +174 -0
  84. traceforge/kicad/edgegeom.py +191 -0
  85. traceforge/kicad/edit.py +405 -0
  86. traceforge/kicad/escape.py +1410 -0
  87. traceforge/kicad/formats.py +1073 -0
  88. traceforge/kicad/impedance.py +320 -0
  89. traceforge/kicad/install.py +75 -0
  90. traceforge/kicad/libraries.py +490 -0
  91. traceforge/kicad/models3d.py +942 -0
  92. traceforge/kicad/outputs.py +81 -0
  93. traceforge/kicad/pcb.py +787 -0
  94. traceforge/kicad/planes.py +1242 -0
  95. traceforge/kicad/project.py +136 -0
  96. traceforge/kicad/route.py +2698 -0
  97. traceforge/kicad/routegeom.py +164 -0
  98. traceforge/kicad/schematic.py +268 -0
  99. traceforge/kicad/sides.py +34 -0
  100. traceforge/kicad/stackup.py +424 -0
  101. traceforge/kicad_plugin/__init__.py +12 -0
  102. traceforge/kicad_plugin/actions.py +92 -0
  103. traceforge/kicad_plugin/cli.py +66 -0
  104. traceforge/kicad_plugin/client.py +86 -0
  105. traceforge/kicad_plugin/context.py +257 -0
  106. traceforge/kicad_plugin/discovery.py +81 -0
  107. traceforge/kicad_plugin/install.py +203 -0
  108. traceforge/kicad_plugin/overlay.py +60 -0
  109. traceforge/kicad_plugin/payload/action_plugin.py +65 -0
  110. traceforge/kicad_plugin/payload/ipc_main.py +42 -0
  111. traceforge/kicad_plugin/payload/plugin.json +90 -0
  112. traceforge/kicad_plugin/payload/requirements.txt +1 -0
  113. traceforge/kicad_plugin/views.py +202 -0
  114. traceforge/langchain.py +57 -0
  115. traceforge/local.py +580 -0
  116. traceforge/mcp_server.py +304 -0
  117. traceforge/models.py +310 -0
  118. traceforge/next_steps.py +110 -0
  119. traceforge/parts/__init__.py +52 -0
  120. traceforge/parts/alternates.py +488 -0
  121. traceforge/parts/bom.py +423 -0
  122. traceforge/parts/brief.py +705 -0
  123. traceforge/parts/catalog.py +297 -0
  124. traceforge/parts/cli_sourcing.py +156 -0
  125. traceforge/parts/data/duty_rates.yaml +37 -0
  126. traceforge/parts/data/hs_codes.yaml +103 -0
  127. traceforge/parts/datasheet.py +278 -0
  128. traceforge/parts/fx.py +100 -0
  129. traceforge/parts/model.py +185 -0
  130. traceforge/parts/net.py +187 -0
  131. traceforge/parts/offers.py +46 -0
  132. traceforge/parts/packages.py +397 -0
  133. traceforge/parts/pinout.py +714 -0
  134. traceforge/parts/providers/__init__.py +17 -0
  135. traceforge/parts/providers/base.py +100 -0
  136. traceforge/parts/providers/jlc.py +364 -0
  137. traceforge/parts/providers/keyed.py +661 -0
  138. traceforge/parts/report.py +508 -0
  139. traceforge/parts/supply.py +330 -0
  140. traceforge/parts/verify.py +455 -0
  141. traceforge/parts/web.py +575 -0
  142. traceforge/plans.py +70 -0
  143. traceforge/preferences.py +127 -0
  144. traceforge/prices.py +130 -0
  145. traceforge/prices.yaml +28 -0
  146. traceforge/prompts/__init__.py +54 -0
  147. traceforge/prompts/system.md +103 -0
  148. traceforge/providers.py +764 -0
  149. traceforge/remote/__init__.py +33 -0
  150. traceforge/remote/client.py +422 -0
  151. traceforge/remote/oidc.py +240 -0
  152. traceforge/remote/orgs.py +510 -0
  153. traceforge/remote/server.py +690 -0
  154. traceforge/remote/store.py +100 -0
  155. traceforge/review_changes.py +187 -0
  156. traceforge/routing.py +99 -0
  157. traceforge/runtime.py +435 -0
  158. traceforge/sessions.py +324 -0
  159. traceforge/sexpr.py +279 -0
  160. traceforge/sim/__init__.py +17 -0
  161. traceforge/sim/acmath.py +234 -0
  162. traceforge/sim/circuit.py +499 -0
  163. traceforge/sim/cli.py +162 -0
  164. traceforge/sim/engine.py +172 -0
  165. traceforge/sim/ltspice.py +175 -0
  166. traceforge/sim/measure.py +123 -0
  167. traceforge/sim/models.py +76 -0
  168. traceforge/sim/project.py +218 -0
  169. traceforge/sim/rawfile.py +123 -0
  170. traceforge/sim/results.py +298 -0
  171. traceforge/sim/tune.py +172 -0
  172. traceforge/skill_commands.py +165 -0
  173. traceforge/skill_library/circuit-simulation/SKILL.md +30 -0
  174. traceforge/skill_library/compliance-review/SKILL.md +51 -0
  175. traceforge/skill_library/design-review/SKILL.md +47 -0
  176. traceforge/skill_library/errata-hunt/SKILL.md +30 -0
  177. traceforge/skill_library/firmware/SKILL.md +26 -0
  178. traceforge/skill_library/power-budget/SKILL.md +27 -0
  179. traceforge/skill_library/release-package/SKILL.md +29 -0
  180. traceforge/skill_library/sourcing-brief/SKILL.md +24 -0
  181. traceforge/skill_library/thermal-review/SKILL.md +15 -0
  182. traceforge/skills.py +131 -0
  183. traceforge/thermal.py +557 -0
  184. traceforge/tools/__init__.py +48 -0
  185. traceforge/tools/agent_tools.py +213 -0
  186. traceforge/tools/base.py +303 -0
  187. traceforge/tools/batch.py +155 -0
  188. traceforge/tools/connections.py +245 -0
  189. traceforge/tools/files.py +777 -0
  190. traceforge/tools/firmware_tools.py +218 -0
  191. traceforge/tools/kicad_tools.py +1072 -0
  192. traceforge/tools/models3d_tools.py +141 -0
  193. traceforge/tools/parts_tools.py +473 -0
  194. traceforge/tools/processes.py +246 -0
  195. traceforge/tools/review_tools.py +178 -0
  196. traceforge/tools/sim_tools.py +230 -0
  197. traceforge/tui.py +620 -0
  198. traceforge/updates.py +124 -0
  199. traceforge/validate/__init__.py +77 -0
  200. traceforge/validate/builtins.py +1091 -0
  201. traceforge/validate/circuits.py +1240 -0
  202. traceforge/validate/config.py +410 -0
  203. traceforge/validate/default.yaml +113 -0
  204. traceforge/validate/design.py +191 -0
  205. traceforge/validate/explain.py +112 -0
  206. traceforge/validate/grounding.py +169 -0
  207. traceforge/validate/ipc2221.py +350 -0
  208. traceforge/validate/multilayer.py +805 -0
  209. traceforge/validate/netmodel.py +261 -0
  210. traceforge/validate/partcheck.py +472 -0
  211. traceforge/validate/report.py +280 -0
  212. traceforge/validate/review.py +322 -0
  213. traceforge/validate/runner.py +662 -0
  214. traceforge/workspace.py +171 -0
  215. traceforge_pcb-1.0.0.dist-info/METADATA +463 -0
  216. traceforge_pcb-1.0.0.dist-info/RECORD +219 -0
  217. traceforge_pcb-1.0.0.dist-info/WHEEL +4 -0
  218. traceforge_pcb-1.0.0.dist-info/entry_points.txt +2 -0
  219. traceforge_pcb-1.0.0.dist-info/licenses/LICENSE +21 -0
@@ -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)
@@ -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"