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.
- snp2le/__init__.py +7 -0
- snp2le/__main__.py +47 -0
- snp2le/app.py +70 -0
- snp2le/cli.py +459 -0
- snp2le/core/__init__.py +1 -0
- snp2le/core/dc.py +123 -0
- snp2le/core/engine.py +110 -0
- snp2le/core/io.py +121 -0
- snp2le/core/ir.py +57 -0
- snp2le/core/mna.py +127 -0
- snp2le/core/netlist.py +298 -0
- snp2le/core/state.py +58 -0
- snp2le/core/structures/__init__.py +22 -0
- snp2le/core/structures/balun.py +187 -0
- snp2le/core/structures/base.py +87 -0
- snp2le/core/structures/branchline.py +181 -0
- snp2le/core/structures/inductor_pi.py +209 -0
- snp2le/core/structures/mim_cap.py +155 -0
- snp2le/core/structures/tline.py +178 -0
- snp2le/core/structures/wilkinson.py +246 -0
- snp2le/core/units.py +95 -0
- snp2le/core/universal.py +196 -0
- snp2le/core/xschem.py +166 -0
- snp2le/examples/balun_ihp-sg13cmos5l.s4p +21 -0
- snp2le/examples/blc_ihp-sg13g2.s4p +82 -0
- snp2le/examples/bpf_ihp-sg13g2.s2p +82 -0
- snp2le/examples/ind_500pH_ihp-sg13cmos5l.s2p +103 -0
- snp2le/examples/ind_d20_w7_sp3_nw1_r10_ihp-sg13g2.s2p +2029 -0
- snp2le/examples/mim_cap_170fF_ihp-sg13g2.s2p +1202 -0
- snp2le/examples/mom_cap_74fF_02_ihp-sg13cmos5l.s2p +13 -0
- snp2le/examples/tline_100um_ihp-sg13g2.s2p +403 -0
- snp2le/examples/wpd_ihp-sg13g2.s3p +82 -0
- snp2le/gui/__init__.py +1 -0
- snp2le/gui/assets/iicqc.png +0 -0
- snp2le/gui/assets/iicqc.svg +150 -0
- snp2le/gui/assets/iicqc_official.svg +344 -0
- snp2le/gui/assets/jku.png +0 -0
- snp2le/gui/assets/jku.svg +77 -0
- snp2le/gui/assets/snp2le.ico +0 -0
- snp2le/gui/assets/snp2le_logo.svg +130 -0
- snp2le/gui/assets/spin_down.svg +1 -0
- snp2le/gui/assets/spin_up.svg +1 -0
- snp2le/gui/combobox_style.py +70 -0
- snp2le/gui/design_view.py +212 -0
- snp2le/gui/footer.py +40 -0
- snp2le/gui/help_dialog.py +182 -0
- snp2le/gui/log_dialog.py +40 -0
- snp2le/gui/logo.py +53 -0
- snp2le/gui/main_window.py +763 -0
- snp2le/gui/mpl_style.py +28 -0
- snp2le/gui/plot_view.py +666 -0
- snp2le/gui/schematic_widget.py +68 -0
- snp2le/gui/style.py +125 -0
- snp2le/gui/top_bar.py +455 -0
- snp2le/gui/widgets.py +110 -0
- snp2le-0.1.1.dist-info/METADATA +284 -0
- snp2le-0.1.1.dist-info/RECORD +61 -0
- snp2le-0.1.1.dist-info/WHEEL +5 -0
- snp2le-0.1.1.dist-info/entry_points.txt +2 -0
- snp2le-0.1.1.dist-info/licenses/LICENSE +201 -0
- 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())
|
snp2le/core/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""core - pure-Python S-parameter -> lumped-element logic (no Qt). Testable in isolation."""
|