snp2le 0.1.1__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 (61) hide show
  1. snp2le/__init__.py +7 -0
  2. snp2le/__main__.py +47 -0
  3. snp2le/app.py +70 -0
  4. snp2le/cli.py +459 -0
  5. snp2le/core/__init__.py +1 -0
  6. snp2le/core/dc.py +123 -0
  7. snp2le/core/engine.py +110 -0
  8. snp2le/core/io.py +121 -0
  9. snp2le/core/ir.py +57 -0
  10. snp2le/core/mna.py +127 -0
  11. snp2le/core/netlist.py +298 -0
  12. snp2le/core/state.py +58 -0
  13. snp2le/core/structures/__init__.py +22 -0
  14. snp2le/core/structures/balun.py +187 -0
  15. snp2le/core/structures/base.py +87 -0
  16. snp2le/core/structures/branchline.py +181 -0
  17. snp2le/core/structures/inductor_pi.py +209 -0
  18. snp2le/core/structures/mim_cap.py +155 -0
  19. snp2le/core/structures/tline.py +178 -0
  20. snp2le/core/structures/wilkinson.py +246 -0
  21. snp2le/core/units.py +95 -0
  22. snp2le/core/universal.py +196 -0
  23. snp2le/core/xschem.py +166 -0
  24. snp2le/examples/balun_ihp-sg13cmos5l.s4p +21 -0
  25. snp2le/examples/blc_ihp-sg13g2.s4p +82 -0
  26. snp2le/examples/bpf_ihp-sg13g2.s2p +82 -0
  27. snp2le/examples/ind_500pH_ihp-sg13cmos5l.s2p +103 -0
  28. snp2le/examples/ind_d20_w7_sp3_nw1_r10_ihp-sg13g2.s2p +2029 -0
  29. snp2le/examples/mim_cap_170fF_ihp-sg13g2.s2p +1202 -0
  30. snp2le/examples/mom_cap_74fF_02_ihp-sg13cmos5l.s2p +13 -0
  31. snp2le/examples/tline_100um_ihp-sg13g2.s2p +403 -0
  32. snp2le/examples/wpd_ihp-sg13g2.s3p +82 -0
  33. snp2le/gui/__init__.py +1 -0
  34. snp2le/gui/assets/iicqc.png +0 -0
  35. snp2le/gui/assets/iicqc.svg +150 -0
  36. snp2le/gui/assets/iicqc_official.svg +344 -0
  37. snp2le/gui/assets/jku.png +0 -0
  38. snp2le/gui/assets/jku.svg +77 -0
  39. snp2le/gui/assets/snp2le.ico +0 -0
  40. snp2le/gui/assets/snp2le_logo.svg +130 -0
  41. snp2le/gui/assets/spin_down.svg +1 -0
  42. snp2le/gui/assets/spin_up.svg +1 -0
  43. snp2le/gui/combobox_style.py +70 -0
  44. snp2le/gui/design_view.py +212 -0
  45. snp2le/gui/footer.py +40 -0
  46. snp2le/gui/help_dialog.py +182 -0
  47. snp2le/gui/log_dialog.py +40 -0
  48. snp2le/gui/logo.py +53 -0
  49. snp2le/gui/main_window.py +763 -0
  50. snp2le/gui/mpl_style.py +28 -0
  51. snp2le/gui/plot_view.py +666 -0
  52. snp2le/gui/schematic_widget.py +68 -0
  53. snp2le/gui/style.py +125 -0
  54. snp2le/gui/top_bar.py +455 -0
  55. snp2le/gui/widgets.py +110 -0
  56. snp2le-0.1.1.dist-info/METADATA +284 -0
  57. snp2le-0.1.1.dist-info/RECORD +61 -0
  58. snp2le-0.1.1.dist-info/WHEEL +5 -0
  59. snp2le-0.1.1.dist-info/entry_points.txt +2 -0
  60. snp2le-0.1.1.dist-info/licenses/LICENSE +201 -0
  61. snp2le-0.1.1.dist-info/top_level.txt +1 -0
snp2le/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """snp2le - convert Touchstone S-parameters into lumped-element netlists.
2
+
3
+ A PySide6 GUI and command-line tool that turns a Touchstone .sNp file into an
4
+ equivalent lumped-element netlist for Ngspice (SPICE3) and VACASK (Spectre),
5
+ through a universal vector-fit macromodel or a structure-specific extractor.
6
+ """
7
+ __version__ = "0.1.1"
snp2le/__main__.py ADDED
@@ -0,0 +1,47 @@
1
+ """snp2le entry point.
2
+
3
+ snp2le launch the graphical interface (default)
4
+ snp2le -b <command> batch (command-line) mode
5
+
6
+ Batch mode forwards everything after -b to the command-line parser, for example:
7
+
8
+ snp2le -b convert design.s2p --mode structure --structure inductor-pi
9
+ snp2le -b list-structures
10
+ snp2le -b -h (full command-line help)
11
+ """
12
+ from __future__ import annotations
13
+ import sys
14
+
15
+ _USAGE = (
16
+ "snp2le - S-parameter to lumped-element netlist converter\n\n"
17
+ " snp2le launch the graphical interface\n"
18
+ " snp2le -b <command> batch (command-line) mode, for example:\n"
19
+ " snp2le -b convert design.s2p --mode structure --structure inductor-pi\n"
20
+ " snp2le -b list-structures\n"
21
+ " snp2le -b -h full command-line help\n"
22
+ )
23
+
24
+
25
+ def main(argv=None):
26
+ """Dispatch: no arguments opens the GUI, -b / --batch runs the command line."""
27
+ argv = list(sys.argv[1:] if argv is None else argv)
28
+ if argv and argv[0] in ("-b", "--batch"):
29
+ from snp2le import cli
30
+ return cli.main(argv[1:])
31
+ if argv and argv[0] in ("-h", "--help"):
32
+ sys.stdout.write(_USAGE)
33
+ return 0
34
+ if argv and argv[0] in ("-V", "--version"):
35
+ from snp2le import __version__
36
+ sys.stdout.write(f"snp2le {__version__}\n")
37
+ return 0
38
+ if argv:
39
+ sys.stderr.write("snp2le: unrecognised arguments (use -b for command-line mode)\n\n")
40
+ sys.stderr.write(_USAGE)
41
+ return 2
42
+ from snp2le import app # no arguments: launch the GUI
43
+ return app.main()
44
+
45
+
46
+ if __name__ == "__main__":
47
+ sys.exit(main())
snp2le/app.py ADDED
@@ -0,0 +1,70 @@
1
+ #!/usr/bin/env python3
2
+ """app.py - launch the snp2le GUI."""
3
+ from __future__ import annotations
4
+ import sys
5
+ from PySide6 import QtWidgets
6
+
7
+ from snp2le.gui.style import build_stylesheet
8
+ from snp2le.gui.mpl_style import apply_style
9
+ from snp2le.gui.logo import logo_icon
10
+ from snp2le.gui.main_window import MainWindow
11
+
12
+
13
+ def _set_windows_app_id():
14
+ """Show our own taskbar icon on Windows instead of the Python logo.
15
+
16
+ Windows groups taskbar buttons by an "Application User Model ID". When the
17
+ GUI is launched via python.exe the process inherits Python's AppID, so the
18
+ taskbar shows the Python icon even though the window icon is ours. Setting an
19
+ explicit AppID detaches us from the interpreter and lets the window icon
20
+ through. Must run before the QApplication is created. It is purely cosmetic, so a
21
+ failure (e.g. non-Windows) is never fatal.
22
+ """
23
+ if sys.platform != "win32":
24
+ return
25
+ try:
26
+ import ctypes
27
+ ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(
28
+ "JKU.IICQC.snp2le")
29
+ except Exception: # noqa: BLE001
30
+ pass
31
+
32
+
33
+ def _install_message_filter():
34
+ """Hide one benign Qt warning, passing every other message through.
35
+
36
+ On some Windows setups Qt prints "QFont::setPointSize: Point size <= 0 (-1)"
37
+ once at startup. It is a side effect of the stylesheet defining fonts in
38
+ pixels (so QFont.pointSize() is -1) being read back by a widget during the
39
+ first paint. Qt keeps the current size, so it is only console noise. We drop
40
+ just that line so genuine warnings stay visible.
41
+ """
42
+ from PySide6 import QtCore
43
+ _default = [None]
44
+
45
+ def _filter(mode, ctx, msg):
46
+ if "setPointSize" in msg:
47
+ return
48
+ if _default[0] is not None:
49
+ _default[0](mode, ctx, msg)
50
+ else:
51
+ sys.stderr.write(msg + "\n")
52
+
53
+ _default[0] = QtCore.qInstallMessageHandler(_filter)
54
+
55
+
56
+ def main():
57
+ _set_windows_app_id()
58
+ _install_message_filter()
59
+ apply_style()
60
+ app = QtWidgets.QApplication(sys.argv)
61
+ app.setApplicationName("snp2le")
62
+ app.setWindowIcon(logo_icon())
63
+ app.setStyleSheet(build_stylesheet())
64
+ win = MainWindow()
65
+ win.show()
66
+ sys.exit(app.exec())
67
+
68
+
69
+ if __name__ == "__main__":
70
+ main()
snp2le/cli.py ADDED
@@ -0,0 +1,459 @@
1
+ #!/usr/bin/env python3
2
+ """cli.py - command-line interface for batch and scripted use.
3
+
4
+ # universal macromodel to an Ngspice netlist
5
+ snp2le -b convert coupler.s4p --mode universal --order 12 -o coupler.spice
6
+
7
+ # structure extraction at 7 GHz, both dialects, print values and tolerances
8
+ snp2le -b convert ind.s2p --mode structure --structure inductor-pi \\
9
+ --fext 7GHz --format both --values --tolerances
10
+
11
+ # convert, run an Xschem testbench, and show data-vs-model-vs-sim plots
12
+ snp2le -b convert bpf.s2p --mode universal --order 13 \\
13
+ -o netlist/spice/bpf_le.spice \\
14
+ --simulate testbenches/xschem/bpf_le_tb_acsp_ngspice.sch --plot
15
+
16
+ Globs are expanded. With --format both, two files are written per input. The exit code is
17
+ non-zero if any conversion or a requested simulation fails.
18
+ """
19
+ from __future__ import annotations
20
+ import argparse
21
+ import glob
22
+ import os
23
+ import re
24
+ import sys
25
+
26
+ from snp2le.core import io, engine, units, netlist
27
+ from snp2le.core.state import ConverterState
28
+ from snp2le.core.structures import structure_items
29
+
30
+ # extensions in sim_data that are never a result table
31
+ _NON_DATA = {".raw", ".spice", ".inc", ".cir", ".net", ".log", ".out", ".svg", ".png",
32
+ ".ps", ".pdf", ".sch", ".aborted"}
33
+ _DATA_EXTS = {".txt", ".data", ".dat", ".csv"}
34
+
35
+
36
+ def _repo_root():
37
+ return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
38
+
39
+
40
+ def _freq(text):
41
+ """argparse type for a frequency, with a readable error."""
42
+ try:
43
+ return units.parse_eng(text)
44
+ except Exception: # noqa: BLE001
45
+ raise argparse.ArgumentTypeError(f"invalid frequency '{text}' (e.g. 7GHz, 2.4e9)")
46
+
47
+
48
+ def _stages(text):
49
+ """argparse type for the RLGC ladder stage count (an integer from 1 to 10)."""
50
+ try:
51
+ n = int(text)
52
+ except Exception: # noqa: BLE001
53
+ raise argparse.ArgumentTypeError(f"invalid stage count '{text}' (an integer 1..10)")
54
+ if not 1 <= n <= 10:
55
+ raise argparse.ArgumentTypeError(f"--stages must be between 1 and 10 (got {n})")
56
+ return n
57
+
58
+
59
+ def _out_path(src, explicit, dialect, n_inputs, n_formats):
60
+ ext = "inc" if dialect == "vacask" else "spice" # VACASK writes .inc
61
+ if explicit and n_inputs == 1:
62
+ if n_formats == 1:
63
+ return explicit
64
+ return f"{os.path.splitext(explicit)[0]}.{ext}" # both: keep -o dir + stem
65
+ base = os.path.splitext(os.path.basename(src))[0] # many inputs: after each source
66
+ return f"{base}.{ext}"
67
+
68
+
69
+ def _valid_sparams(names, n):
70
+ """Keep only S-parameter selectors valid for an n-port, warning about the rest."""
71
+ out = []
72
+ for name in names:
73
+ m = re.fullmatch(r"[sS]([1-9])([1-9])", name)
74
+ if m and int(m.group(1)) <= n and int(m.group(2)) <= n:
75
+ out.append(f"S{m.group(1)}{m.group(2)}")
76
+ else:
77
+ print(f"[WARN] ignoring plot selector '{name}' (not an S-parameter of a "
78
+ f"{n}-port)", file=sys.stderr)
79
+ return out
80
+
81
+
82
+ def _print_values(res):
83
+ if not res.value_rows:
84
+ return
85
+ print(" element values:")
86
+ for lab, val, unit in res.value_rows:
87
+ s = units.format_eng(val, unit) if unit else f"{val:.4g}"
88
+ print(f" {lab:8s} = {s}")
89
+
90
+
91
+ def _print_tolerances(res):
92
+ if not res.value_drift:
93
+ return
94
+ print(" tolerances (|data-model|/model at f_ext):")
95
+ for lab, pct in res.value_drift.items():
96
+ print(f" {lab:8s} = {pct:.1f}%")
97
+
98
+
99
+ def _default_sparams(n):
100
+ if n == 2:
101
+ return ["S11", "S21", "S12", "S22"]
102
+ return (["S11"] + [f"S{i}1" for i in range(2, n + 1)])[:4] # match plus couplings
103
+
104
+
105
+ def _tail_log(path, header):
106
+ """Print the tail of a simulator log file (VACASK's captured console) to stderr."""
107
+ if not path or not os.path.exists(path):
108
+ return
109
+ try:
110
+ with open(path, errors="replace") as fh:
111
+ txt = fh.read().strip()
112
+ except OSError:
113
+ return
114
+ if txt:
115
+ print(f" --- {header} ---", file=sys.stderr)
116
+ print(" " + txt[-1500:].replace("\n", "\n "), file=sys.stderr)
117
+
118
+
119
+ def _find_result(sim_data, stem, start):
120
+ """Newest result file freshly written by this run: prefer one named after the testbench,
121
+ else any data-style file (Ngspice wrdata targets vary)."""
122
+ if not os.path.isdir(sim_data):
123
+ return None
124
+ named, data = [], []
125
+ for f in os.listdir(sim_data):
126
+ ext = os.path.splitext(f)[1].lower()
127
+ if ext in _NON_DATA:
128
+ continue
129
+ p = os.path.join(sim_data, f)
130
+ try:
131
+ mt = os.path.getmtime(p)
132
+ except OSError:
133
+ continue
134
+ if mt < start - 1: # not (re)written this run
135
+ continue
136
+ (named if f.startswith(stem) else data).append((mt, p))
137
+ pool = named or [x for x in data if os.path.splitext(x[1])[1].lower() in _DATA_EXTS]
138
+ return max(pool)[1] if pool else None
139
+
140
+
141
+ def _run_testbench(sch, simulator, show_output, net, timeout):
142
+ """Run an Xschem testbench with `simulator` and return the imported result path, or None.
143
+
144
+ Both Ngspice and VACASK are launched detached by xschem (which returns at once with an
145
+ empty console), so the outcome is read the same way for both: sync the sweep to the loaded
146
+ data, then poll for the result while the simulator process is alive. When the process has
147
+ exited with no result the run failed, and VACASK's captured log and .aborted marker give
148
+ the specific cause."""
149
+ import subprocess
150
+ import time
151
+ from snp2le.core import xschem
152
+ if not xschem.available():
153
+ print("xschem not found on PATH, cannot run a testbench", file=sys.stderr)
154
+ return None
155
+ sch = os.path.abspath(sch)
156
+ if not os.path.isfile(sch):
157
+ print(f"testbench not found: {sch}", file=sys.stderr)
158
+ return None
159
+
160
+ prog, args, cwd = xschem.simulate_command(sch, show_output=show_output, simulator=simulator)
161
+ os.makedirs(os.path.join(cwd, "simulations"), exist_ok=True)
162
+ if net is not None: # the sweep follows the loaded data
163
+ try:
164
+ xschem.write_sim_range(cwd, float(net.f[0]), float(net.f[-1]))
165
+ except (TypeError, IndexError, ValueError, OSError):
166
+ pass
167
+
168
+ sim_data = os.path.join(_repo_root(), "sim_data")
169
+ stem = os.path.splitext(os.path.basename(sch))[0]
170
+ marker = os.path.join(sim_data, stem + ".aborted")
171
+ log = xschem.sim_log_path(sch, simulator) if simulator == "vacask" else None
172
+ for stale in (os.path.join(sim_data, stem + ".txt"), marker): # a clean run this time
173
+ try:
174
+ os.remove(stale)
175
+ except OSError:
176
+ pass
177
+
178
+ env = os.environ.copy()
179
+ if simulator == "vacask" and show_output: # let the postprocess pop its plot
180
+ env["SHOW_PLOTS"] = "1"
181
+ print(f" running {os.path.basename(sch)} with {simulator}...")
182
+ start = time.time()
183
+ try:
184
+ subprocess.run([prog, *args], cwd=cwd, env=env,
185
+ capture_output=not show_output, text=True, timeout=timeout)
186
+ except subprocess.TimeoutExpired:
187
+ print(f" xschem did not return within {timeout:.0f} s", file=sys.stderr)
188
+ return None
189
+
190
+ settle = [None]
191
+
192
+ def settled(): # appeared and stopped growing
193
+ r = _find_result(sim_data, stem, start)
194
+ if r is None:
195
+ return None
196
+ try:
197
+ size = os.path.getsize(r)
198
+ except OSError:
199
+ return None
200
+ if size > 0 and settle[0] == (r, size):
201
+ return r
202
+ settle[0] = (r, size)
203
+ return None
204
+
205
+ def aborted():
206
+ try:
207
+ return os.path.getmtime(marker) >= start - 1
208
+ except OSError:
209
+ return False
210
+
211
+ def fail(msg):
212
+ print(" " + msg, file=sys.stderr)
213
+ _tail_log(log, "VACASK console")
214
+ if simulator != "vacask":
215
+ print(" Ngspice keeps no log here; run the testbench directly or open it in "
216
+ "xschem to see the error", file=sys.stderr)
217
+ return None
218
+
219
+ seen, gone_at, deadline = False, 0.0, start + timeout
220
+ while time.time() < deadline:
221
+ r = settled()
222
+ if r is not None:
223
+ if show_output and log:
224
+ _tail_log(log, "VACASK console")
225
+ return r
226
+ if simulator == "vacask" and aborted():
227
+ return fail("the analysis aborted (e.g. a singular matrix), no result written")
228
+ if xschem.simulator_running(simulator):
229
+ seen, gone_at = True, 0.0
230
+ elif seen: # ran, then exited with no result
231
+ gone_at = gone_at or time.time()
232
+ if time.time() - gone_at > 1.5:
233
+ return fail(f"{simulator} finished without writing a result")
234
+ elif time.time() - start > 12.0: # never even started / instant fail
235
+ return fail(f"{simulator} produced no result (check the testbench and models)")
236
+ time.sleep(0.3)
237
+ return fail(f"no result for '{stem}' after {timeout:.0f} s")
238
+
239
+
240
+ def _show_plots(res, sim, sparams):
241
+ import numpy as np
242
+ import matplotlib
243
+ if matplotlib.get_backend().lower().endswith("agg"): # no interactive display
244
+ raise RuntimeError("no display available (matplotlib backend is non-interactive)")
245
+ import matplotlib.pyplot as plt
246
+ f = np.asarray(res.freq, float) / 1e9
247
+ data = np.asarray(res.data_s)
248
+ model = np.asarray(res.model_s)
249
+ n = len(sparams)
250
+ fig, ax = plt.subplots(2, n, figsize=(3.6 * n, 6.0), squeeze=False)
251
+ for c, name in enumerate(sparams):
252
+ i, j = int(name[1]) - 1, int(name[2]) - 1
253
+ d, m = data[:, i, j], model[:, i, j]
254
+ a0, a1 = ax[0][c], ax[1][c]
255
+ a0.plot(f, 20 * np.log10(np.abs(d) + 1e-12), color="0.5", lw=1.4, label="data")
256
+ a0.plot(f, 20 * np.log10(np.abs(m) + 1e-12), "b--", lw=1.6, label="model")
257
+ a1.plot(f, np.unwrap(np.angle(d)) * 180 / np.pi, color="0.5", lw=1.4)
258
+ a1.plot(f, np.unwrap(np.angle(m)) * 180 / np.pi, "b--", lw=1.6)
259
+ if sim and name in sim:
260
+ sf = np.asarray(sim["f"], float) / 1e9
261
+ if sim[name].get("db") is not None:
262
+ a0.plot(sf, sim[name]["db"], "r-.", lw=1.8, label="sim")
263
+ if sim[name].get("deg") is not None:
264
+ ph = np.unwrap(np.deg2rad(np.asarray(sim[name]["deg"], float))) * 180 / np.pi
265
+ a1.plot(sf, ph, "r-.", lw=1.8)
266
+ a0.set_title(name)
267
+ a0.set_ylabel("|S| (dB)")
268
+ a0.grid(alpha=.3)
269
+ a1.set_ylabel("phase (deg)")
270
+ a1.set_xlabel("f (GHz)")
271
+ a1.grid(alpha=.3)
272
+ if c == 0:
273
+ a0.legend(fontsize=8)
274
+ fig.tight_layout()
275
+ plt.show()
276
+
277
+
278
+ def cmd_convert(args):
279
+ if args.mode == "structure":
280
+ valid = {k for k, _, _ in structure_items()}
281
+ if args.structure not in valid:
282
+ print(f"unknown structure '{args.structure}'. Valid keys: "
283
+ f"{', '.join(sorted(valid))}", file=sys.stderr)
284
+ return 2
285
+
286
+ paths = []
287
+ for pat in args.inputs:
288
+ hits = sorted(glob.glob(pat))
289
+ if not hits and not os.path.exists(pat):
290
+ print(f"[WARN] no file matches '{pat}'", file=sys.stderr)
291
+ paths.extend(hits or [pat])
292
+ if not paths:
293
+ print("no input files", file=sys.stderr)
294
+ return 2
295
+ if args.output and len(paths) > 1:
296
+ print("[WARN] -o is ignored with multiple inputs; each output is named after its "
297
+ "source", file=sys.stderr)
298
+
299
+ formats = ["ngspice", "vacask"] if args.format == "both" else [args.format]
300
+ rc = 0
301
+ last_res = None
302
+ last_net = None
303
+ for src in paths:
304
+ try:
305
+ net = io.load_touchstone(src)
306
+ except Exception as exc: # noqa: BLE001
307
+ print(f"[FAIL] {src}: {exc}", file=sys.stderr)
308
+ rc = 1
309
+ continue
310
+ state = ConverterState(
311
+ mode=args.mode, structure_key=args.structure,
312
+ max_order=args.order, enforce_passivity=args.passive,
313
+ f_extract=args.fext, n_segments=args.stages, iso_resistor=args.iso_r)
314
+ res = engine.convert(state, net)
315
+ if not res.ok:
316
+ print(f"[FAIL] {src}: {res.error}", file=sys.stderr)
317
+ rc = 1
318
+ continue
319
+ last_res, last_net = res, net
320
+ for dialect in formats:
321
+ out = _out_path(src, args.output, dialect, len(paths), len(formats))
322
+ if res.ir is not None:
323
+ # name the .SUBCKT after the output file (bpf_le.spice -> bpf_le), so a
324
+ # testbench that instantiates that name resolves the .include. The GUI export
325
+ # does the same. The default 's_equivalent' would not match the testbench.
326
+ res.ir.name = netlist.safe_subckt_name(
327
+ os.path.splitext(os.path.basename(out))[0])
328
+ text = (netlist.render_vacask(res.ir) if dialect == "vacask"
329
+ else netlist.render_ngspice(res.ir))
330
+ else:
331
+ text = res.vacask if dialect == "vacask" else res.ngspice
332
+ try:
333
+ parent = os.path.dirname(os.path.abspath(out))
334
+ os.makedirs(parent, exist_ok=True)
335
+ with open(out, "w", encoding="utf-8") as fh:
336
+ fh.write(text)
337
+ except OSError as exc:
338
+ print(f"[FAIL] {src} -> {out}: {exc}", file=sys.stderr)
339
+ rc = 1
340
+ continue
341
+ if not args.quiet:
342
+ if res.mode == "universal":
343
+ dc = ("" if res.dc is None
344
+ else " dc=solvable" if res.dc.ok else " dc=SINGULAR")
345
+ extra = f"rms={res.rms_error:.2e} poles={res.n_poles}{dc}"
346
+ else:
347
+ extra = f"f_ext={units.format_eng(res.metrics.get('f_extract'), 'Hz')}"
348
+ print(f"[ OK ] {src} -> {out} ({dialect}, {extra})")
349
+ # a singular DC operating point makes the netlist unsimulable, so always warn
350
+ if res.dc is not None and not res.dc.ok:
351
+ print(f"[WARN] {src}: DC operating point may be singular "
352
+ f"(margin {res.dc.margin:.0e}); try a lower --order or --passive",
353
+ file=sys.stderr)
354
+ if not args.quiet:
355
+ for m in res.messages:
356
+ print(f" note: {m}")
357
+ if args.values:
358
+ _print_values(res)
359
+ if args.tolerances:
360
+ _print_tolerances(res)
361
+
362
+ # optional: run a testbench, then show the data-vs-model-vs-sim plots
363
+ sim = None
364
+ if args.simulate and last_res is None:
365
+ print("[WARN] no successful conversion; skipping --simulate", file=sys.stderr)
366
+ elif args.simulate:
367
+ if len(paths) > 1:
368
+ print("[WARN] --simulate uses the last converted file; the testbench's DUT must "
369
+ "match it", file=sys.stderr)
370
+ simr = args.simulator or ("vacask" if "vacask" in args.simulate.lower() else "ngspice")
371
+ if simr not in formats:
372
+ print(f"[WARN] simulating with {simr} but only {'/'.join(formats)} was exported; "
373
+ f"the testbench may not find its DUT netlist (use --format {simr} or both)",
374
+ file=sys.stderr)
375
+ result = _run_testbench(args.simulate, simr, args.show_output, last_net, args.timeout)
376
+ if result:
377
+ try:
378
+ sim = io.load_ngspice_sim(result)
379
+ print(f"[ OK ] imported simulation {os.path.basename(result)}")
380
+ except Exception as exc: # noqa: BLE001
381
+ print(f"[WARN] could not parse {result}: {exc}", file=sys.stderr)
382
+ rc = rc or 1
383
+ else:
384
+ rc = rc or 1
385
+ if args.plot is not None and last_res is not None and last_res.model_s is not None:
386
+ req = (_default_sparams(last_res.n_ports) if args.plot == "auto"
387
+ else [s.strip() for s in args.plot.split(",") if s.strip()])
388
+ sel = _valid_sparams(req, last_res.n_ports)
389
+ if sel:
390
+ try:
391
+ _show_plots(last_res, sim, sel)
392
+ except Exception as exc: # noqa: BLE001
393
+ print(f"[WARN] could not display plots (a display is needed): {exc}",
394
+ file=sys.stderr)
395
+ return rc
396
+
397
+
398
+ def build_parser():
399
+ p = argparse.ArgumentParser(
400
+ prog="snp2le -b", formatter_class=argparse.RawDescriptionHelpFormatter,
401
+ description="Touchstone S-parameters to a lumped-element netlist")
402
+ sub = p.add_subparsers(dest="cmd", required=True)
403
+ c = sub.add_parser("convert", help="convert one or more .sNp files",
404
+ formatter_class=argparse.RawDescriptionHelpFormatter)
405
+ c.add_argument("inputs", nargs="+", help=".sNp file(s) or glob(s)")
406
+ # mode and model
407
+ c.add_argument("--mode", choices=["universal", "structure"], default="universal")
408
+ c.add_argument("--structure", default="inductor-pi",
409
+ help="structure key when --mode structure (see list-structures)")
410
+ # universal-mode options
411
+ c.add_argument("--order", type=int, default=6, help="max model order (universal)")
412
+ c.add_argument("--passive", action="store_true", default=True,
413
+ help="enforce passivity (universal, default on)")
414
+ c.add_argument("--no-passive", dest="passive", action="store_false")
415
+ # structure-mode options
416
+ c.add_argument("--fext", type=_freq, default=10e9, metavar="FREQ",
417
+ help="extraction frequency, e.g. 7GHz (structure)")
418
+ c.add_argument("--stages", type=_stages, default=2,
419
+ help="RLGC ladder cells, 1 to 10 (tline-rlgc)")
420
+ c.add_argument("--iso-r", dest="iso_r", action="store_true", default=True,
421
+ help="include the Wilkinson isolation R or branch-line arm loss")
422
+ c.add_argument("--no-iso-r", dest="iso_r", action="store_false")
423
+ # output
424
+ c.add_argument("--format", choices=["ngspice", "vacask", "both"], default="ngspice")
425
+ c.add_argument("-o", "--output", default=None, help="output path (single input)")
426
+ c.add_argument("--values", action="store_true", help="print the extracted element values")
427
+ c.add_argument("--tolerances", action="store_true", help="print per-element tolerances")
428
+ c.add_argument("--quiet", action="store_true", help="suppress the per-file status line")
429
+ # simulate a testbench and plot
430
+ c.add_argument("--simulate", metavar="SCH", help="run an Xschem testbench after converting")
431
+ c.add_argument("--simulator", choices=["ngspice", "vacask"], default=None,
432
+ help="simulator for --simulate (default: auto from the .sch name)")
433
+ c.add_argument("--show-output", action="store_true",
434
+ help="show the simulator console and plot windows during the run")
435
+ c.add_argument("--timeout", type=float, default=180.0, metavar="S",
436
+ help="seconds to wait for a --simulate result (default 180)")
437
+ c.add_argument("--plot", nargs="?", const="auto", default=None, metavar="SPARAMS",
438
+ help="display data-vs-model plots (optional comma list, e.g. S11,S21)")
439
+ c.set_defaults(func=cmd_convert)
440
+
441
+ sub.add_parser("list-structures", help="list available structure keys")
442
+ return p
443
+
444
+
445
+ def main(argv=None):
446
+ args = build_parser().parse_args(argv)
447
+ if args.cmd == "list-structures":
448
+ for key, name, nports in structure_items():
449
+ print(f"{key:18s} {name} ({nports}-port)")
450
+ return 0
451
+ try:
452
+ return args.func(args)
453
+ except KeyboardInterrupt:
454
+ print("\ninterrupted", file=sys.stderr)
455
+ return 130
456
+
457
+
458
+ if __name__ == "__main__":
459
+ sys.exit(main())
@@ -0,0 +1 @@
1
+ """core - pure-Python S-parameter -> lumped-element logic (no Qt). Testable in isolation."""