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,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]}")