setupEM 0.3.15__tar.gz → 0.4.0__tar.gz

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 (26) hide show
  1. {setupem-0.3.15 → setupem-0.4.0}/PKG-INFO +29 -4
  2. {setupem-0.3.15 → setupem-0.4.0}/README.md +26 -1
  3. setupem-0.3.15/src/setupEM.egg-info/PKG-INFO → setupem-0.4.0/README_pypi.md +27 -21
  4. {setupem-0.3.15 → setupem-0.4.0}/pyproject.toml +2 -1
  5. {setupem-0.3.15 → setupem-0.4.0}/src/setupEM/__init__.py +1 -1
  6. setupem-0.4.0/src/setupEM/result_viewer.py +659 -0
  7. {setupem-0.3.15 → setupem-0.4.0}/src/setupEM/setupEM.py +88 -20
  8. {setupem-0.3.15 → setupem-0.4.0}/src/setupEM/setup_common.py +18 -4
  9. setupem-0.3.15/README_pypi.md → setupem-0.4.0/src/setupEM.egg-info/PKG-INFO +46 -2
  10. {setupem-0.3.15 → setupem-0.4.0}/src/setupEM.egg-info/SOURCES.txt +1 -0
  11. {setupem-0.3.15 → setupem-0.4.0}/src/setupEM.egg-info/entry_points.txt +1 -0
  12. {setupem-0.3.15 → setupem-0.4.0}/src/setupEM.egg-info/requires.txt +1 -1
  13. {setupem-0.3.15 → setupem-0.4.0}/LICENSE +0 -0
  14. {setupem-0.3.15 → setupem-0.4.0}/setup.cfg +0 -0
  15. {setupem-0.3.15 → setupem-0.4.0}/src/setupEM/__main__.py +0 -0
  16. {setupem-0.3.15 → setupem-0.4.0}/src/setupEM/data/SG13G2_100um.xml +0 -0
  17. {setupem-0.3.15 → setupem-0.4.0}/src/setupEM/data/SG13G2_200um.xml +0 -0
  18. {setupem-0.3.15 → setupem-0.4.0}/src/setupEM/data/SG13G2_200um_with_parameters.xml +0 -0
  19. {setupem-0.3.15 → setupem-0.4.0}/src/setupEM/data/SG13G2_nosub.xml +0 -0
  20. {setupem-0.3.15 → setupem-0.4.0}/src/setupEM/momentum_import.py +0 -0
  21. {setupem-0.3.15 → setupem-0.4.0}/src/setupEM/palace_results.py +0 -0
  22. {setupem-0.3.15 → setupem-0.4.0}/src/setupEM/setupThermal.py +0 -0
  23. {setupem-0.3.15 → setupem-0.4.0}/src/setupEM/stackupEditor.py +0 -0
  24. {setupem-0.3.15 → setupem-0.4.0}/src/setupEM/stackup_writer.py +0 -0
  25. {setupem-0.3.15 → setupem-0.4.0}/src/setupEM.egg-info/dependency_links.txt +0 -0
  26. {setupem-0.3.15 → setupem-0.4.0}/src/setupEM.egg-info/top_level.txt +0 -0
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: setupEM
3
- Version: 0.3.15
3
+ Version: 0.4.0
4
4
  Summary: Python tool for configuration of gds2palace workflow with GUI.
5
5
  Author-email: Volker Muehlhaus <volker@muehlhaus.com>
6
6
  License-Expression: GPL-3.0-or-later
7
7
  Requires-Python: >=3.9
8
8
  Description-Content-Type: text/markdown
9
9
  License-File: LICENSE
10
- Requires-Dist: gds2palace>=0.3.6
10
+ Requires-Dist: gds2palace>=0.4.0
11
11
  Requires-Dist: PySide6
12
12
  Requires-Dist: scipy
13
13
  Requires-Dist: requests
@@ -21,6 +21,7 @@ Dynamic: license-file
21
21
 
22
22
  ## What's New
23
23
 
24
+ - A built-in **Result Viewer** (Create Model tab > View Results...) to browse and plot Touchstone S-parameter results without leaving setupEM
24
25
  - **setupThermal**, a companion app for Elmer thermal simulation, alongside setupEM
25
26
  - A graphical **Stackup XML Editor** (Tools > Edit Stackup XML...), including Variables, Reference-relative positioning, Derived Layers, and Thermal Tables
26
27
  - Input Files tab can now **override stackup Variables** (e.g. `total_thickness`) directly, without hand-editing the XML or the generated model
@@ -126,7 +127,7 @@ On this tab, you configure input files:
126
127
 
127
128
  The fields for GDSII and XML file support drag & drop or you can use the Browse... buttons.
128
129
 
129
- Some pre-processing of the layout is also defined here: You can specify a distance (in micron) which is used for **via array merging**, to speed up simulation by replacing many individual vias with one large via box. If your layout includes **polygons with holes**, you need to set the "Preprocess GDSII file" checkbox, otherwise you will get error messages during meshing.
130
+ Some pre-processing of the layout is also defined here: You can specify a distance (in micron) which is used for **via array merging**, to speed up simulation by replacing many individual vias with one large via box.
130
131
 
131
132
  <img src="https://raw.githubusercontent.com/VolkerMuehlhaus/setupEM/main/doc/png/inputfiles1.png" alt="input files" width="700">
132
133
 
@@ -205,6 +206,30 @@ To start simulation on Windows from the WSL terminal, type
205
206
 
206
207
  To **create Touchstone SnP output** from simulation results, please have a look at the scripts directory. Script "combine_snp" runs Python code "combine_extend_snp.py", which scans your directories (working directory and below) and converts simulation results to Touchstone file format. Supported input file format: Palace and Elmer S-parameter data.
207
208
 
209
+ ## Result Viewer
210
+
211
+ Once you have Touchstone SnP results, click **View Results...** on the Create Model tab to open the built-in **Result Viewer** - no need to run the standalone `plot_snp.py` script by hand.
212
+
213
+ <img src="https://raw.githubusercontent.com/VolkerMuehlhaus/setupEM/main/doc/png/resultviewer_button.png" alt="view results button" width="700">
214
+
215
+ The Result Viewer recursively scans the Target Directory for `.sNp` Touchstone files and lists them in a tree, grouped by the folder each file came from. Check individual files to overlay them, or check/uncheck a whole run's group entry to select or deselect every file below it at once.
216
+ The **Include _dc files** / **Include _deembedded files** checkboxes filter out DC-extrapolated and de-embedded variants created by `combine_extend_snp.py`, so you can start with just the raw result and bring in the others only when needed.
217
+
218
+ <img src="https://raw.githubusercontent.com/VolkerMuehlhaus/setupEM/main/doc/png/resultviewer1.png" alt="result viewer" width="750">
219
+
220
+ Pick which S-parameters to plot from the S-Parameters grid.
221
+ Checking files across several runs, or a whole run group, overlays all of them at once:
222
+
223
+ <img src="https://raw.githubusercontent.com/VolkerMuehlhaus/setupEM/main/doc/png/resultviewer2.png" alt="result viewer multi-file overlay" width="750">
224
+
225
+ For reflection parameters (S11, S22, ...), the Display panel can switch to a **Smith chart** or a **zoomed Smith chart** instead of dB+phase.
226
+
227
+ <img src="https://raw.githubusercontent.com/VolkerMuehlhaus/setupEM/main/doc/png/resultviewer3.png" alt="result viewer smith chart" width="750">
228
+
229
+ The matplotlib toolbar above the plot (pan/zoom/save as PNG) works as usual. A file with only a single simulated frequency point is marked with a dot instead of a line, since there is nothing to draw a line between.
230
+
231
+ Result Viewer can also be run standalone, without the full setupEM GUI, either directly (`python result_viewer.py [target_dir]`) or via the `resultViewer` console script installed with the package.
232
+
208
233
  ## Code
209
234
  Behind the scenes, the setupEM user interface created Python model code for gds2palace, and you can check the resulting code on the "Code" tab.
210
235
 
@@ -272,7 +297,7 @@ This project is licensed under the GNU General Public License v3.0 or later (GPL
272
297
 
273
298
  **Note:** the `setupEM` PyPI package itself requires:
274
299
 
275
- - gds2palace>=0.3.6
300
+ - gds2palace>=0.4.0
276
301
  - PySide6
277
302
  - scipy
278
303
  - requests
@@ -2,6 +2,7 @@
2
2
 
3
3
  ## What's New
4
4
 
5
+ - A built-in **Result Viewer** (Create Model tab > View Results...) to browse and plot Touchstone S-parameter results without leaving setupEM
5
6
  - **setupThermal**, a companion app for Elmer thermal simulation, alongside setupEM
6
7
  - A graphical **Stackup XML Editor** (Tools > Edit Stackup XML...), including Variables, Reference-relative positioning, Derived Layers, and Thermal Tables
7
8
  - Input Files tab can now **override stackup Variables** (e.g. `total_thickness`) directly, without hand-editing the XML or the generated model
@@ -107,7 +108,7 @@ On this tab, you configure input files:
107
108
 
108
109
  The fields for GDSII and XML file support drag & drop or you can use the Browse... buttons.
109
110
 
110
- Some pre-processing of the layout is also defined here: You can specify a distance (in micron) which is used for **via array merging**, to speed up simulation by replacing many individual vias with one large via box. If your layout includes **polygons with holes**, you need to set the "Preprocess GDSII file" checkbox, otherwise you will get error messages during meshing.
111
+ Some pre-processing of the layout is also defined here: You can specify a distance (in micron) which is used for **via array merging**, to speed up simulation by replacing many individual vias with one large via box.
111
112
 
112
113
  <img src="./doc/png/inputfiles1.png" alt="input files" width="700">
113
114
 
@@ -186,6 +187,30 @@ To start simulation on Windows from the WSL terminal, type
186
187
 
187
188
  To **create Touchstone SnP output** from simulation results, please have a look at the scripts directory. Script "combine_snp" runs Python code "combine_extend_snp.py", which scans your directories (working directory and below) and converts simulation results to Touchstone file format. Supported input file format: Palace and Elmer S-parameter data.
188
189
 
190
+ ## Result Viewer
191
+
192
+ Once you have Touchstone SnP results, click **View Results...** on the Create Model tab to open the built-in **Result Viewer** - no need to run the standalone `plot_snp.py` script by hand.
193
+
194
+ <img src="./doc/png/resultviewer_button.png" alt="view results button" width="700">
195
+
196
+ The Result Viewer recursively scans the Target Directory for `.sNp` Touchstone files and lists them in a tree, grouped by the folder each file came from. Check individual files to overlay them, or check/uncheck a whole run's group entry to select or deselect every file below it at once.
197
+ The **Include _dc files** / **Include _deembedded files** checkboxes filter out DC-extrapolated and de-embedded variants created by `combine_extend_snp.py`, so you can start with just the raw result and bring in the others only when needed.
198
+
199
+ <img src="./doc/png/resultviewer1.png" alt="result viewer" width="750">
200
+
201
+ Pick which S-parameters to plot from the S-Parameters grid.
202
+ Checking files across several runs, or a whole run group, overlays all of them at once:
203
+
204
+ <img src="./doc/png/resultviewer2.png" alt="result viewer multi-file overlay" width="750">
205
+
206
+ For reflection parameters (S11, S22, ...), the Display panel can switch to a **Smith chart** or a **zoomed Smith chart** instead of dB+phase.
207
+
208
+ <img src="./doc/png/resultviewer3.png" alt="result viewer smith chart" width="750">
209
+
210
+ The matplotlib toolbar above the plot (pan/zoom/save as PNG) works as usual. A file with only a single simulated frequency point is marked with a dot instead of a line, since there is nothing to draw a line between.
211
+
212
+ Result Viewer can also be run standalone, without the full setupEM GUI, either directly (`python result_viewer.py [target_dir]`) or via the `resultViewer` console script installed with the package.
213
+
189
214
  ## Code
190
215
  Behind the scenes, the setupEM user interface created Python model code for gds2palace, and you can check the resulting code on the "Code" tab.
191
216
 
@@ -1,26 +1,8 @@
1
- Metadata-Version: 2.4
2
- Name: setupEM
3
- Version: 0.3.15
4
- Summary: Python tool for configuration of gds2palace workflow with GUI.
5
- Author-email: Volker Muehlhaus <volker@muehlhaus.com>
6
- License-Expression: GPL-3.0-or-later
7
- Requires-Python: >=3.9
8
- Description-Content-Type: text/markdown
9
- License-File: LICENSE
10
- Requires-Dist: gds2palace>=0.3.6
11
- Requires-Dist: PySide6
12
- Requires-Dist: scipy
13
- Requires-Dist: requests
14
- Requires-Dist: scikit-rf
15
- Requires-Dist: matplotlib
16
- Requires-Dist: numpy
17
- Requires-Dist: gdspy
18
- Dynamic: license-file
19
-
20
1
  # Python GUI for gds2palace
21
2
 
22
3
  ## What's New
23
4
 
5
+ - A built-in **Result Viewer** (Create Model tab > View Results...) to browse and plot Touchstone S-parameter results without leaving setupEM
24
6
  - **setupThermal**, a companion app for Elmer thermal simulation, alongside setupEM
25
7
  - A graphical **Stackup XML Editor** (Tools > Edit Stackup XML...), including Variables, Reference-relative positioning, Derived Layers, and Thermal Tables
26
8
  - Input Files tab can now **override stackup Variables** (e.g. `total_thickness`) directly, without hand-editing the XML or the generated model
@@ -126,7 +108,7 @@ On this tab, you configure input files:
126
108
 
127
109
  The fields for GDSII and XML file support drag & drop or you can use the Browse... buttons.
128
110
 
129
- Some pre-processing of the layout is also defined here: You can specify a distance (in micron) which is used for **via array merging**, to speed up simulation by replacing many individual vias with one large via box. If your layout includes **polygons with holes**, you need to set the "Preprocess GDSII file" checkbox, otherwise you will get error messages during meshing.
111
+ Some pre-processing of the layout is also defined here: You can specify a distance (in micron) which is used for **via array merging**, to speed up simulation by replacing many individual vias with one large via box.
130
112
 
131
113
  <img src="https://raw.githubusercontent.com/VolkerMuehlhaus/setupEM/main/doc/png/inputfiles1.png" alt="input files" width="700">
132
114
 
@@ -205,6 +187,30 @@ To start simulation on Windows from the WSL terminal, type
205
187
 
206
188
  To **create Touchstone SnP output** from simulation results, please have a look at the scripts directory. Script "combine_snp" runs Python code "combine_extend_snp.py", which scans your directories (working directory and below) and converts simulation results to Touchstone file format. Supported input file format: Palace and Elmer S-parameter data.
207
189
 
190
+ ## Result Viewer
191
+
192
+ Once you have Touchstone SnP results, click **View Results...** on the Create Model tab to open the built-in **Result Viewer** - no need to run the standalone `plot_snp.py` script by hand.
193
+
194
+ <img src="https://raw.githubusercontent.com/VolkerMuehlhaus/setupEM/main/doc/png/resultviewer_button.png" alt="view results button" width="700">
195
+
196
+ The Result Viewer recursively scans the Target Directory for `.sNp` Touchstone files and lists them in a tree, grouped by the folder each file came from. Check individual files to overlay them, or check/uncheck a whole run's group entry to select or deselect every file below it at once.
197
+ The **Include _dc files** / **Include _deembedded files** checkboxes filter out DC-extrapolated and de-embedded variants created by `combine_extend_snp.py`, so you can start with just the raw result and bring in the others only when needed.
198
+
199
+ <img src="https://raw.githubusercontent.com/VolkerMuehlhaus/setupEM/main/doc/png/resultviewer1.png" alt="result viewer" width="750">
200
+
201
+ Pick which S-parameters to plot from the S-Parameters grid.
202
+ Checking files across several runs, or a whole run group, overlays all of them at once:
203
+
204
+ <img src="https://raw.githubusercontent.com/VolkerMuehlhaus/setupEM/main/doc/png/resultviewer2.png" alt="result viewer multi-file overlay" width="750">
205
+
206
+ For reflection parameters (S11, S22, ...), the Display panel can switch to a **Smith chart** or a **zoomed Smith chart** instead of dB+phase.
207
+
208
+ <img src="https://raw.githubusercontent.com/VolkerMuehlhaus/setupEM/main/doc/png/resultviewer3.png" alt="result viewer smith chart" width="750">
209
+
210
+ The matplotlib toolbar above the plot (pan/zoom/save as PNG) works as usual. A file with only a single simulated frequency point is marked with a dot instead of a line, since there is nothing to draw a line between.
211
+
212
+ Result Viewer can also be run standalone, without the full setupEM GUI, either directly (`python result_viewer.py [target_dir]`) or via the `resultViewer` console script installed with the package.
213
+
208
214
  ## Code
209
215
  Behind the scenes, the setupEM user interface created Python model code for gds2palace, and you can check the resulting code on the "Code" tab.
210
216
 
@@ -272,7 +278,7 @@ This project is licensed under the GNU General Public License v3.0 or later (GPL
272
278
 
273
279
  **Note:** the `setupEM` PyPI package itself requires:
274
280
 
275
- - gds2palace>=0.3.6
281
+ - gds2palace>=0.4.0
276
282
  - PySide6
277
283
  - scipy
278
284
  - requests
@@ -12,7 +12,7 @@ authors = [
12
12
  { name = "Volker Muehlhaus", email = "volker@muehlhaus.com" }
13
13
  ]
14
14
  dependencies = [
15
- "gds2palace>=0.3.6",
15
+ "gds2palace>=0.4.0",
16
16
  "PySide6",
17
17
  "scipy",
18
18
  "requests",
@@ -27,6 +27,7 @@ requires-python = ">=3.9"
27
27
  setupEM = "setupEM.setupEM:main"
28
28
  setupThermal = "setupEM.setupThermal:main"
29
29
  stackupEditor = "setupEM.stackupEditor:main"
30
+ resultViewer = "setupEM.result_viewer:main"
30
31
 
31
32
  [tool.setuptools.dynamic]
32
33
  version = {attr = "setupEM.__version__"}
@@ -24,4 +24,4 @@ A Python tool for EM setup using gds2palace.
24
24
  from .setupEM import main
25
25
 
26
26
  __all__ = ["main"]
27
- __version__ = "0.3.15"
27
+ __version__ = "0.4.0"
@@ -0,0 +1,659 @@
1
+ ########################################################################
2
+ #
3
+ # Copyright 2025-2026 Volker Muehlhaus and IHP PDK Authors
4
+ #
5
+ # Licensed under the GNU General Public License, Version 3.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # https://www.gnu.org/licenses/gpl-3.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+ #
17
+ ########################################################################
18
+
19
+ """
20
+ result_viewer.py
21
+
22
+ GUI S-parameter result viewer: a PySide6 equivalent of the standalone
23
+ plot_snp.py script, embedded in one window instead of popping up separate
24
+ matplotlib windows per plot type. Lists Touchstone (.sNp) files found
25
+ recursively under a target directory, lets the user check any number of
26
+ them to overlay, pick which S-parameters to plot from a dynamically sized
27
+ grid, and choose whether reflection (Snn) parameters show phase, a Smith
28
+ chart, or a zoomed Smith chart in place of the rectangular phase plot.
29
+
30
+ Normally opened from setupEM's Create Model tab (View Results button, see
31
+ MainWindow.open_result_viewer() in setupEM.py) - but also runnable
32
+ standalone, either directly (`python result_viewer.py [target_dir]`) or via
33
+ the `resultViewer` console script installed with this package (see main()
34
+ below and pyproject.toml).
35
+ """
36
+
37
+ import argparse
38
+ import math
39
+ import os
40
+ import re
41
+ import sys
42
+
43
+ import numpy as np
44
+ import skrf as rf
45
+
46
+ from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg, NavigationToolbar2QT
47
+ from matplotlib.figure import Figure
48
+ from matplotlib.lines import Line2D
49
+ from matplotlib.patches import Circle
50
+ from matplotlib.ticker import MultipleLocator
51
+
52
+ from PySide6.QtWidgets import (
53
+ QApplication, QDialog, QVBoxLayout, QHBoxLayout, QGridLayout,
54
+ QGroupBox, QLabel, QTreeWidget, QTreeWidgetItem, QPushButton,
55
+ QRadioButton, QButtonGroup, QCheckBox, QSizePolicy, QStyleFactory,
56
+ )
57
+ from PySide6.QtCore import Qt
58
+
59
+
60
+ # ------------------------------------------------------------------
61
+ # Plot data helpers (ported from plot_snp.py)
62
+ # ------------------------------------------------------------------
63
+
64
+ COLORS = ['r', 'b', 'm', 'c', 'g', 'y', 'k', 'w']
65
+ LINESTYLES = ['solid', 'dashed', 'dashdot', 'dotted', 'solid', 'dashed', 'dashdot', 'dotted']
66
+
67
+ # marker size for the fat dot marking a network with a single frequency point
68
+ # (no line can be drawn between points, so it would otherwise be invisible)
69
+ SINGLE_POINT_MARKERSIZE = 7
70
+
71
+ # reflection coefficient magnitude shown in the zoomed Smith chart
72
+ ZOOM_GAMMA = 0.5
73
+ GRID_COLOR = 'lightgrey'
74
+ GRID_LW = 0.8
75
+ # constant-resistance/-reactance grid values for the zoomed Smith chart,
76
+ # denser than skrf's default labeled grid ([0.2, 0.5, 1, 2, 5])
77
+ ZOOM_GRID_VALUES = [0.2, 0.5, 1.0, 1.5, 2.0, 3.0]
78
+
79
+ TOUCHSTONE_RE = re.compile(r'\.s(\d+)p$', re.IGNORECASE)
80
+
81
+
82
+ def dB(value):
83
+ return 20.0 * np.log10(np.abs(value))
84
+
85
+
86
+ def phase_deg(value):
87
+ return np.angle(value, deg=True)
88
+
89
+
90
+ def Sxx(network, m, n):
91
+ return network.s[:, m-1, n-1]
92
+
93
+
94
+ def draw_zoomed_smith_grid(ax, gamma):
95
+ # draw a denser Smith chart grid for the zoomed view, with labels placed where
96
+ # each grid circle crosses the real (for r) or imaginary (for x) axis, since
97
+ # skrf's own label placement is designed for the full chart and would fall
98
+ # outside the zoomed axis limits
99
+ ax.axhline(0, color='grey', lw=0.5)
100
+
101
+ for r in ZOOM_GRID_VALUES:
102
+ center = (r/(1+r), 0)
103
+ radius = 1/(1+r)
104
+ ax.add_patch(Circle(center, radius, ec=GRID_COLOR, fc='none', lw=GRID_LW))
105
+ label_pos = center[0] - radius
106
+ if abs(label_pos) < gamma:
107
+ ax.annotate(f"{r:g}", xy=(label_pos, 0), xytext=(label_pos, 0.01),
108
+ fontsize=8, color='dimgrey', ha='center', va='bottom')
109
+
110
+ for sign in (1, -1):
111
+ for x in ZOOM_GRID_VALUES:
112
+ xv = sign * x
113
+ center = (1, 1/xv)
114
+ radius = abs(1/xv)
115
+ ax.add_patch(Circle(center, radius, ec=GRID_COLOR, fc='none', lw=GRID_LW))
116
+ if radius >= 1:
117
+ # crossing point with the imaginary axis nearest the origin
118
+ y0 = 1/xv - math.copysign(math.sqrt(radius**2 - 1), 1/xv)
119
+ if abs(y0) < gamma:
120
+ ax.annotate(f"{xv:g}j", xy=(0, y0), xytext=(0.01, y0),
121
+ fontsize=8, color='dimgrey', ha='left', va='center')
122
+
123
+ ax.plot(gamma*np.array([-1.1, 1.1]), gamma*np.array([-1.1, 1.1]), 'w.', markersize=0)
124
+
125
+
126
+ def draw_rectangular(ax, m, n, plotted, mode):
127
+ """Draw dB magnitude (mode="db") or phase (mode="phase") of Smn, one line per
128
+ (network, color, linestyle, label) tuple in plotted, into the given ax. No
129
+ per-axis legend - every subplot shows the same file set, so redraw_plot()
130
+ draws one shared legend for the whole figure instead."""
131
+ func = dB if mode == "db" else phase_deg
132
+ label_prefix = 'dB' if mode == "db" else 'phase'
133
+ for network, color, linestyle, label in plotted:
134
+ data = func(Sxx(network, m, n))
135
+ freq = network.frequency.f / 1e9
136
+ if len(freq) == 1:
137
+ # a single frequency point has no line to draw between points and
138
+ # would otherwise be invisible - mark it with a fat dot instead
139
+ ax.plot(freq, data, color=color, linestyle=linestyle, label=label,
140
+ marker='o', markersize=SINGLE_POINT_MARKERSIZE)
141
+ else:
142
+ ax.plot(freq, data, color=color, linestyle=linestyle, label=label)
143
+ ax.set_xlabel("Frequency (GHz)")
144
+ ax.set_ylabel(f"{label_prefix} S{m}{n}")
145
+ ax.set_xmargin(0)
146
+ if mode == "phase":
147
+ # phase_deg() is np.angle(..., deg=True), always wrapped to (-180, 180] -
148
+ # fix the axis to that full range with a clean 45-degree grid instead of
149
+ # leaving it to matplotlib's autoscale, which picks an arbitrary spacing
150
+ # (e.g. 50/100) that doesn't divide the natural -180..180 range evenly
151
+ ax.set_ylim(-180, 180)
152
+ ax.yaxis.set_major_locator(MultipleLocator(45))
153
+ ax.grid()
154
+
155
+
156
+ def draw_smith(ax, m, n, plotted, zoomed):
157
+ """Draw a Smith chart (or zoomed Smith chart) of reflection parameter Smm/Snn,
158
+ one trace per (network, color, linestyle, label) tuple in plotted, into ax."""
159
+ if zoomed:
160
+ draw_zoomed_smith_grid(ax, ZOOM_GAMMA)
161
+ for network, color, linestyle, label in plotted:
162
+ data = Sxx(network, m, n)
163
+ if len(data) == 1:
164
+ # a single frequency point has no line to draw between points
165
+ # and would otherwise be invisible - mark it with a fat dot
166
+ ax.plot(data.real, data.imag, color=color, linestyle=linestyle,
167
+ label=label, marker='o', markersize=SINGLE_POINT_MARKERSIZE)
168
+ else:
169
+ ax.plot(data.real, data.imag, color=color, linestyle=linestyle, label=label)
170
+ ax.set_xlim(-ZOOM_GAMMA, ZOOM_GAMMA)
171
+ ax.set_ylim(-ZOOM_GAMMA, ZOOM_GAMMA)
172
+ ax.set_xticks([])
173
+ ax.set_yticks([])
174
+ else:
175
+ for network, color, linestyle, label in plotted:
176
+ marker_kwargs = {'marker': 'o', 'markersize': SINGLE_POINT_MARKERSIZE} if Sxx(network, m, n).size == 1 else {}
177
+ network.plot_s_smith(m-1, n-1, ax=ax, show_legend=False, draw_labels=True,
178
+ color=color, linestyle=linestyle, label=label, **marker_kwargs)
179
+ # skrf draws the grid circles with hardcoded colors (black for r=0/1, x=+-1);
180
+ # recolor them to match the zoomed chart's uniform light grid style
181
+ for patch in ax.patches:
182
+ patch.set_edgecolor(GRID_COLOR)
183
+ patch.set_linewidth(GRID_LW)
184
+
185
+ ax.set_title(f"S{m}{n}")
186
+ ax.set_aspect('equal')
187
+ # no per-axis legend here - every Smith column shows the same file set, so
188
+ # redraw_plot() draws one shared legend for the whole figure instead (a
189
+ # per-axis legend placed outside each small subplot got clipped by the
190
+ # figure edge or the next subplot once more than one column was shown)
191
+
192
+
193
+ def find_touchstone_files(target_dir):
194
+ """Recursively find every Touchstone (.sNp, any port count) file at or below
195
+ target_dir. Returns a sorted list of absolute paths, or [] for any bad input
196
+ (empty/missing directory) - never raises."""
197
+ if not target_dir or not os.path.isdir(target_dir):
198
+ return []
199
+ matches = []
200
+ for root, dirnames, files in os.walk(target_dir):
201
+ dirnames[:] = [d for d in dirnames if not d.startswith('.')]
202
+ for fn in files:
203
+ if TOUCHSTONE_RE.search(fn):
204
+ matches.append(os.path.join(root, fn))
205
+ return sorted(matches)
206
+
207
+
208
+ # ------------------------------------------------------------------
209
+ # Result Viewer window
210
+ # ------------------------------------------------------------------
211
+
212
+ class ResultViewerWindow(QDialog):
213
+ """Own top-level window (no Qt parent, WA_DeleteOnClose - same lifecycle as
214
+ StackupEditorWindow in stackupEditor.py) that lists Touchstone files under
215
+ MainWindow.saved_values['sim_path'] and plots the ones checked."""
216
+
217
+ def __init__(self, MainWindow):
218
+ super().__init__()
219
+ self.setAttribute(Qt.WA_DeleteOnClose)
220
+ self.MainWindow = MainWindow
221
+
222
+ self._target_dir = ''
223
+ self._master_files = [] # sorted absolute paths, last scan
224
+ self._checked_paths = set() # subset of _master_files currently checked
225
+ self._checked_params = {(1, 1)} # set of (m, n) S-parameters to plot
226
+ self._network_cache = {} # path -> (mtime, skrf.Network | None)
227
+ self._last_n = None # common port count as of last parameter-grid rebuild
228
+ self.smith_mode = "phase" # "phase" | "smith" | "zoom"
229
+ self._updating_checks = False # re-entrancy guard for group<->leaf checkbox propagation
230
+
231
+ self._build_ui()
232
+ self._rescan_files()
233
+
234
+ # ---------- UI construction ----------
235
+
236
+ def _build_ui(self):
237
+ self.setWindowTitle("Result Viewer")
238
+ self.resize(1200, 800)
239
+ main_layout = QVBoxLayout(self)
240
+
241
+ controls_layout = QHBoxLayout()
242
+
243
+ files_group = QGroupBox("Files")
244
+ files_layout = QVBoxLayout()
245
+ filter_layout = QHBoxLayout()
246
+ self.include_dc_cb = QCheckBox("Include _dc files")
247
+ self.include_dc_cb.setChecked(False) # start showing only the raw result file
248
+ self.include_dc_cb.toggled.connect(self._rescan_files)
249
+ filter_layout.addWidget(self.include_dc_cb)
250
+ self.include_deembedded_cb = QCheckBox("Include _deembedded files")
251
+ self.include_deembedded_cb.setChecked(False) # start showing only the raw result file
252
+ self.include_deembedded_cb.toggled.connect(self._rescan_files)
253
+ filter_layout.addWidget(self.include_deembedded_cb)
254
+ filter_layout.addStretch()
255
+ files_layout.addLayout(filter_layout)
256
+ self.file_list = QTreeWidget()
257
+ self.file_list.setHeaderHidden(True)
258
+ self.file_list.itemChanged.connect(self._on_file_item_changed)
259
+ files_layout.addWidget(self.file_list)
260
+ files_group.setLayout(files_layout)
261
+ controls_layout.addWidget(files_group, 2)
262
+
263
+ param_group = QGroupBox("S-Parameters")
264
+ self.param_grid_layout = QGridLayout()
265
+ param_group.setLayout(self.param_grid_layout)
266
+ controls_layout.addWidget(param_group, 1)
267
+
268
+ display_group = QGroupBox("Display")
269
+ display_layout = QVBoxLayout()
270
+ self.phase_radio = QRadioButton("dB + Phase")
271
+ self.smith_radio = QRadioButton("Smith chart")
272
+ self.zoom_radio = QRadioButton("Smith chart (zoomed)")
273
+ self.phase_radio.setChecked(True)
274
+ self.display_button_group = QButtonGroup(self)
275
+ for rb in (self.phase_radio, self.smith_radio, self.zoom_radio):
276
+ self.display_button_group.addButton(rb)
277
+ display_layout.addWidget(rb)
278
+ rb.toggled.connect(self._on_mode_changed)
279
+ self.refresh_btn = QPushButton("Refresh")
280
+ self.refresh_btn.clicked.connect(self._rescan_files)
281
+ display_layout.addWidget(self.refresh_btn)
282
+ self.warning_label = QLabel("")
283
+ self.warning_label.setWordWrap(True)
284
+ self.warning_label.setStyleSheet("color: #b00000;")
285
+ display_layout.addWidget(self.warning_label)
286
+ display_group.setLayout(display_layout)
287
+ controls_layout.addWidget(display_group, 1)
288
+
289
+ main_layout.addLayout(controls_layout)
290
+
291
+ # constrained layout (not tight_layout()) recomputes margins on every draw,
292
+ # including window resizes - tight_layout() only computes them once at the
293
+ # call site and goes stale (clipped axis labels) as the Qt widget is resized
294
+ self.figure = Figure(figsize=(10, 6), layout='constrained')
295
+ self.canvas = FigureCanvasQTAgg(self.figure)
296
+ self.canvas.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
297
+ self.nav_toolbar = NavigationToolbar2QT(self.canvas, self)
298
+ main_layout.addWidget(self.nav_toolbar)
299
+ main_layout.addWidget(self.canvas, 1)
300
+
301
+ # ---------- Qt event hooks ----------
302
+
303
+ def showEvent(self, event):
304
+ super().showEvent(event)
305
+ self._rescan_files()
306
+
307
+ # ---------- File list ----------
308
+
309
+ def _relpath_for_path(self, path):
310
+ """Full relative-to-target-dir path, untruncated - used for the file list,
311
+ which has room to show it in full (or scroll) rather than shortening it."""
312
+ rel = os.path.relpath(path, self._target_dir) if self._target_dir else path
313
+ return rel.replace('\\', '/')
314
+
315
+ def _legend_label_for_path(self, path):
316
+ """Shortened label for plot legends, which have much less room than the
317
+ file list."""
318
+ rel = self._relpath_for_path(path)
319
+ if len(rel) <= 17:
320
+ return rel
321
+ return rel[:10] + '..' + rel[-20:]
322
+
323
+ def _filtered_files(self, files):
324
+ include_dc = self.include_dc_cb.isChecked()
325
+ include_deembedded = self.include_deembedded_cb.isChecked()
326
+ result = []
327
+ for path in files:
328
+ name = os.path.basename(path)
329
+ if not include_dc and '_dc' in name:
330
+ continue
331
+ if not include_deembedded and '_deembedded' in name:
332
+ continue
333
+ result.append(path)
334
+ return result
335
+
336
+ def _rescan_files(self):
337
+ saved_values = self.MainWindow.saved_values
338
+ target_dir = saved_values.get('sim_path', '') if isinstance(saved_values, dict) else ''
339
+ self._target_dir = target_dir.replace('\\', '/') if target_dir else ''
340
+
341
+ self.file_list.blockSignals(True)
342
+ self.file_list.clear()
343
+
344
+ if not target_dir:
345
+ self._master_files = []
346
+ item = QTreeWidgetItem(["No Target Directory set (see Create Model tab)."])
347
+ item.setFlags(Qt.NoItemFlags)
348
+ self.file_list.addTopLevelItem(item)
349
+ elif not os.path.isdir(target_dir):
350
+ self._master_files = []
351
+ item = QTreeWidgetItem([f"Target Directory does not exist: {target_dir}"])
352
+ item.setFlags(Qt.NoItemFlags)
353
+ self.file_list.addTopLevelItem(item)
354
+ else:
355
+ all_files = find_touchstone_files(target_dir)
356
+ self._master_files = self._filtered_files(all_files)
357
+ if not self._master_files:
358
+ if all_files:
359
+ message = "No files match the current _dc/_deembedded filters " \
360
+ f"under {target_dir}"
361
+ else:
362
+ message = f"No Touchstone (.sNp) files found under {target_dir}"
363
+ item = QTreeWidgetItem([message])
364
+ item.setFlags(Qt.NoItemFlags)
365
+ self.file_list.addTopLevelItem(item)
366
+ else:
367
+ # drop checked paths that no longer exist; auto-check the newest
368
+ # file if nothing is checked (e.g. first open), so the window
369
+ # isn't blank
370
+ self._checked_paths &= set(self._master_files)
371
+ if not self._checked_paths:
372
+ newest = max(self._master_files, key=os.path.getmtime)
373
+ self._checked_paths = {newest}
374
+
375
+ # group by each file's immediate parent directory (relative to
376
+ # target_dir), not a Palace/Elmer-specific convention like
377
+ # <model>_data, so this stays correct for either output layout.
378
+ # Files sitting directly in target_dir (parent == "") get no
379
+ # wrapper group node - they're added straight to the tree.
380
+ groups = {}
381
+ for path in self._master_files:
382
+ parent = os.path.dirname(self._relpath_for_path(path))
383
+ groups.setdefault(parent, []).append(path)
384
+
385
+ for path in groups.pop("", []):
386
+ self.file_list.addTopLevelItem(self._make_file_item(path))
387
+ for parent in sorted(groups):
388
+ group_item = QTreeWidgetItem([parent])
389
+ # checkable so the whole group can be checked/unchecked at once
390
+ # (propagated to/from its children in _on_file_item_changed);
391
+ # not given Qt.ItemIsAutoTristate - propagation is done manually
392
+ # below so exactly one _on_control_changed()/redraw happens per
393
+ # user action, not one per child
394
+ group_item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsUserCheckable)
395
+ self.file_list.addTopLevelItem(group_item)
396
+ for path in groups[parent]:
397
+ group_item.addChild(self._make_file_item(path))
398
+ self._refresh_group_checkstate(group_item)
399
+
400
+ self.file_list.expandAll()
401
+
402
+ self.file_list.blockSignals(False)
403
+ self._on_control_changed()
404
+
405
+ def _make_file_item(self, path):
406
+ item = QTreeWidgetItem([os.path.basename(path)])
407
+ item.setFlags(item.flags() | Qt.ItemIsUserCheckable)
408
+ item.setData(0, Qt.UserRole, path)
409
+ item.setCheckState(0, Qt.Checked if path in self._checked_paths else Qt.Unchecked)
410
+ return item
411
+
412
+ def _on_file_item_changed(self, item, column=0):
413
+ if self._updating_checks:
414
+ return # this change is itself a propagation side-effect below - ignore it
415
+ path = item.data(0, Qt.UserRole)
416
+ # placeholder rows are never checkable, so itemChanged never fires for them;
417
+ # anything checkable with no path data here is a group header
418
+ is_group = path is None
419
+
420
+ self._updating_checks = True
421
+ try:
422
+ if is_group:
423
+ state = item.checkState(0)
424
+ for i in range(item.childCount()):
425
+ child = item.child(i)
426
+ child.setCheckState(0, state)
427
+ child_path = child.data(0, Qt.UserRole)
428
+ if state == Qt.Checked:
429
+ self._checked_paths.add(child_path)
430
+ else:
431
+ self._checked_paths.discard(child_path)
432
+ else:
433
+ if item.checkState(0) == Qt.Checked:
434
+ self._checked_paths.add(path)
435
+ else:
436
+ self._checked_paths.discard(path)
437
+ parent = item.parent()
438
+ if parent is not None:
439
+ self._refresh_group_checkstate(parent)
440
+ finally:
441
+ self._updating_checks = False
442
+
443
+ self._on_control_changed()
444
+
445
+ def _refresh_group_checkstate(self, group_item):
446
+ """Set a group header's own checkbox to reflect its children: checked if
447
+ all children are checked, unchecked if none are, partially-checked if mixed."""
448
+ states = [group_item.child(i).checkState(0) for i in range(group_item.childCount())]
449
+ if all(s == Qt.Checked for s in states):
450
+ group_item.setCheckState(0, Qt.Checked)
451
+ elif all(s == Qt.Unchecked for s in states):
452
+ group_item.setCheckState(0, Qt.Unchecked)
453
+ else:
454
+ group_item.setCheckState(0, Qt.PartiallyChecked)
455
+
456
+ # ---------- Network loading ----------
457
+
458
+ def _load_network_cached(self, path):
459
+ try:
460
+ mtime = os.path.getmtime(path)
461
+ except OSError:
462
+ return None
463
+ cached = self._network_cache.get(path)
464
+ if cached is not None and cached[0] == mtime:
465
+ return cached[1]
466
+ try:
467
+ network = rf.Network(path)
468
+ except Exception:
469
+ network = None
470
+ self._network_cache[path] = (mtime, network)
471
+ return network
472
+
473
+ def _get_checked_plotted(self):
474
+ """Return [(network, color, linestyle, label), ...] for currently checked,
475
+ successfully-loaded files, and update the warning label for any that failed
476
+ to load. Color/linestyle are keyed to position among the checked, loaded
477
+ files (master-list order), so the first curve is always COLORS[0]/
478
+ LINESTYLES[0] (red, solid) - matches plot_snp.py's own convention. Colors
479
+ can shift for other files as check state changes; that trade-off is
480
+ accepted so "the first curve" always means red/solid."""
481
+ checked_in_order = [path for path in self._master_files if path in self._checked_paths]
482
+ networks = []
483
+ warnings = []
484
+ for path in checked_in_order:
485
+ network = self._load_network_cached(path)
486
+ label = self._legend_label_for_path(path)
487
+ if network is None:
488
+ warnings.append(label)
489
+ else:
490
+ networks.append((network, label))
491
+ self.warning_label.setText("Failed to load: " + ", ".join(warnings) if warnings else "")
492
+ return [
493
+ (network, COLORS[i % len(COLORS)], LINESTYLES[i % len(LINESTYLES)], label)
494
+ for i, (network, label) in enumerate(networks)
495
+ ]
496
+
497
+ def _current_common_nports(self):
498
+ plotted = self._get_checked_plotted()
499
+ if not plotted:
500
+ return 0
501
+ return min(network.nports for network, _, _, _ in plotted)
502
+
503
+ # ---------- S-parameter picker ----------
504
+
505
+ def _rebuild_parameter_grid(self, n):
506
+ while self.param_grid_layout.count():
507
+ child = self.param_grid_layout.takeAt(0)
508
+ widget = child.widget()
509
+ if widget is not None:
510
+ widget.deleteLater()
511
+
512
+ self._checked_params = {(m, k) for (m, k) in self._checked_params if m <= n and k <= n}
513
+ if n >= 1 and not self._checked_params:
514
+ self._checked_params = {(1, 1)} # never let the plot go silently empty
515
+
516
+ if n == 0:
517
+ self.param_grid_layout.addWidget(QLabel("Check a file to choose S-parameters"), 0, 0)
518
+ else:
519
+ for m in range(1, n + 1):
520
+ for k in range(1, n + 1):
521
+ btn = QPushButton(f"S{m}{k}")
522
+ btn.setCheckable(True)
523
+ btn.setChecked((m, k) in self._checked_params)
524
+ btn.setFixedWidth(50)
525
+ btn.toggled.connect(lambda checked, m=m, k=k: self._on_param_toggled(m, k, checked))
526
+ self.param_grid_layout.addWidget(btn, m - 1, k - 1)
527
+
528
+ self.redraw_plot()
529
+
530
+ def _on_param_toggled(self, m, n, checked):
531
+ if checked:
532
+ self._checked_params.add((m, n))
533
+ else:
534
+ self._checked_params.discard((m, n))
535
+ self.redraw_plot()
536
+
537
+ # ---------- Display mode ----------
538
+
539
+ def _on_mode_changed(self, checked):
540
+ if not checked:
541
+ return # QButtonGroup fires toggled(False) for the button losing the selection too
542
+ if self.smith_radio.isChecked():
543
+ self.smith_mode = "smith"
544
+ elif self.zoom_radio.isChecked():
545
+ self.smith_mode = "zoom"
546
+ else:
547
+ self.smith_mode = "phase"
548
+ self.redraw_plot()
549
+
550
+ # ---------- Redraw ----------
551
+
552
+ def _on_control_changed(self):
553
+ n = self._current_common_nports()
554
+ if n != self._last_n:
555
+ self._last_n = n
556
+ self._rebuild_parameter_grid(n) # rebuilds grid and redraws
557
+ else:
558
+ self.redraw_plot()
559
+
560
+ def redraw_plot(self):
561
+ self.figure.clear()
562
+ plotted = self._get_checked_plotted() # also sets warning_label for load failures
563
+ params = sorted(self._checked_params)
564
+
565
+ if not plotted or not params:
566
+ ax = self.figure.add_subplot(111)
567
+ ax.axis('off')
568
+ ax.text(0.5, 0.5, "Check a file and at least one S-parameter to plot",
569
+ ha='center', va='center', transform=ax.transAxes)
570
+ self.canvas.draw_idle()
571
+ return
572
+
573
+ if self.smith_mode in ("smith", "zoom"):
574
+ # Smith/zoomed-Smith replaces dB+phase entirely - only reflection (Snn)
575
+ # parameters have a Smith representation, so non-reflection selections
576
+ # are left out of this view (noted in the warning label) rather than
577
+ # shown as an empty/meaningless chart
578
+ reflection_params = [(m, n) for (m, n) in params if m == n]
579
+ excluded = [(m, n) for (m, n) in params if m != n]
580
+ if excluded:
581
+ note = "Not shown in Smith view (not reflection): " + \
582
+ ", ".join(f"S{m}{n}" for m, n in excluded)
583
+ current = self.warning_label.text()
584
+ self.warning_label.setText((current + " " if current else "") + note)
585
+
586
+ if not reflection_params:
587
+ ax = self.figure.add_subplot(111)
588
+ ax.axis('off')
589
+ ax.text(0.5, 0.5, "No reflection (Snn) parameter selected for Smith view",
590
+ ha='center', va='center', transform=ax.transAxes)
591
+ self.canvas.draw_idle()
592
+ return
593
+
594
+ axes = self.figure.subplots(1, len(reflection_params), squeeze=False)
595
+ for a, (m, n) in enumerate(reflection_params):
596
+ draw_smith(axes[0][a], m, n, plotted, zoomed=(self.smith_mode == "zoom"))
597
+ else:
598
+ axes = self.figure.subplots(2, len(params), squeeze=False)
599
+ for a, (m, n) in enumerate(params):
600
+ draw_rectangular(axes[0][a], m, n, plotted, mode="db")
601
+ draw_rectangular(axes[1][a], m, n, plotted, mode="phase")
602
+
603
+ self.figure.suptitle("S Parameters")
604
+ self._draw_shared_legend(plotted)
605
+ self.canvas.draw_idle()
606
+
607
+ def _draw_shared_legend(self, plotted):
608
+ """One legend below the whole figure instead of one per subplot - every
609
+ subplot shows the same file set, and a per-axis legend got clipped by the
610
+ figure edge or a neighboring subplot once more than one column was shown.
611
+ Proxy handles (not pulled from an axis) so this doesn't depend on which
612
+ internal artists a particular draw helper (e.g. skrf's plot_s_smith)
613
+ created."""
614
+ handles = [Line2D([0], [0], color=color, linestyle=linestyle)
615
+ for _, color, linestyle, _ in plotted]
616
+ labels = [label for _, _, _, label in plotted]
617
+ # "outside lower center" (not "lower center") is what makes the constrained
618
+ # layout engine reserve room for this legend below the axes on every draw,
619
+ # instead of the legend floating over/under-clipped by the figure edge
620
+ self.figure.legend(handles, labels, loc='outside lower center',
621
+ ncol=min(len(plotted), 4), fontsize=8)
622
+
623
+
624
+ # ------------------------------------------------------------------
625
+ # Standalone launch (python result_viewer.py [target_dir], or the
626
+ # resultViewer console script - see pyproject.toml)
627
+ # ------------------------------------------------------------------
628
+
629
+ class _StandaloneMainWindow:
630
+ """Minimal stand-in for the real setupEM MainWindow, used only when this module
631
+ is run on its own rather than opened from within the full app (Create Model
632
+ tab's View Results button). Provides just the saved_values dict
633
+ ResultViewerWindow needs from its MainWindow argument."""
634
+ APP_NAME = "Result Viewer"
635
+
636
+ def __init__(self, target_dir):
637
+ self.saved_values = {'sim_path': target_dir}
638
+
639
+
640
+ def main():
641
+ app = QApplication(sys.argv)
642
+ if sys.platform.startswith("win"):
643
+ # matches setupEM.py's/setupThermal.py's main() - without this, Qt's default
644
+ # style on Windows looks visibly different (fonts/widget chrome) from the full app
645
+ app.setStyle(QStyleFactory.create("Windows"))
646
+
647
+ parser = argparse.ArgumentParser(description="Standalone S-parameter result viewer")
648
+ parser.add_argument("target_dir", nargs="?", default=os.getcwd(),
649
+ help="directory to search recursively for Touchstone (.sNp) "
650
+ "files (default: current directory)")
651
+ args = parser.parse_args()
652
+
653
+ window = ResultViewerWindow(_StandaloneMainWindow(args.target_dir))
654
+ window.show()
655
+ sys.exit(app.exec())
656
+
657
+
658
+ if __name__ == "__main__":
659
+ main()
@@ -181,29 +181,43 @@ class FrequenciesTab(QWidget):
181
181
 
182
182
 
183
183
  def save_values(self):
184
- try:
185
- fstart = float(self.start_edit.text())
186
- except Exception:
187
- # the only case when this field can be empty is when fpoint or fdump are defined
188
- if self.start_edit.text()=="" and (self.fpoint_edit.text() != "" or self.fdump_edit.text() != ""):
189
- return True
184
+ # fstart/fstop may be left empty only if fpoint or fdump supplies at least
185
+ # one frequency instead (gds2palace treats fstart/fstop as fully optional)
186
+ discrete_freqs_given = (self.fpoint_edit.text() != "" or self.fdump_edit.text() != "")
187
+
188
+ fstart = None
189
+ if self.start_edit.text() == "":
190
+ if discrete_freqs_given:
191
+ saved_values.pop("fstart", None) # don't leave stale sweep data behind
190
192
  else:
191
193
  QMessageBox.warning(self, "Error", "Not a valid value for fstart")
192
194
  self.start_edit.setText("0")
193
195
  return False
194
- saved_values ["fstart"] = float(fstart)
196
+ else:
197
+ try:
198
+ fstart = float(self.start_edit.text())
199
+ except Exception:
200
+ QMessageBox.warning(self, "Error", "Not a valid value for fstart")
201
+ self.start_edit.setText("0")
202
+ return False
203
+ saved_values ["fstart"] = fstart
195
204
 
196
- try:
197
- fstop = float(self.stop_edit.text())
198
- except Exception:
199
- # the only case when this field can be empty is when fpoint or fdump are defined
200
- if self.stop_edit.text()=="" and (self.fpoint_edit.text() != "" or self.fdump_edit.text() != ""):
201
- return True
205
+ fstop = None
206
+ if self.stop_edit.text() == "":
207
+ if discrete_freqs_given:
208
+ saved_values.pop("fstop", None) # don't leave stale sweep data behind
202
209
  else:
203
210
  QMessageBox.warning(self, "Error", "Not a valid value for fstop")
204
211
  self.stop_edit.setText("50")
205
212
  return False
206
- saved_values ["fstop"] = float(fstop)
213
+ else:
214
+ try:
215
+ fstop = float(self.stop_edit.text())
216
+ except Exception:
217
+ QMessageBox.warning(self, "Error", "Not a valid value for fstop")
218
+ self.stop_edit.setText("50")
219
+ return False
220
+ saved_values ["fstop"] = fstop
207
221
 
208
222
  if self.step_edit.text() != "":
209
223
  try:
@@ -231,10 +245,13 @@ class FrequenciesTab(QWidget):
231
245
  saved_values.pop("fdump",None)
232
246
 
233
247
  # if fstart == fstop == fdump or fstart == fstop == fstep, then remove fstart, fstop
234
- if saved_values ["fstart"] == saved_values ["fstop"]:
248
+ if fstart is not None and fstop is not None and fstart == fstop:
235
249
  discrete_list1 = saved_values.get("fpoint", [])
236
250
  discrete_list2 = saved_values.get("fdump", [])
237
- if saved_values ["fstart"] in discrete_list1 or saved_values ["fstart"] in discrete_list2:
251
+ if fstart in discrete_list1 or fstart in discrete_list2:
252
+ saved_values.pop("fstart", None)
253
+ saved_values.pop("fstop", None)
254
+ saved_values.pop("fstep", None)
238
255
  self.start_edit.setText("")
239
256
  self.stop_edit.setText("")
240
257
  self.step_edit.setText("")
@@ -243,8 +260,13 @@ class FrequenciesTab(QWidget):
243
260
  return True # Tab change only possible when returning True
244
261
 
245
262
  def load_values(self):
246
- self.start_edit.setText(str(saved_values.get("fstart","0")))
247
- self.stop_edit.setText(str(saved_values.get("fstop","50")))
263
+ # if fstart/fstop were intentionally omitted in favor of fpoint/fdump, keep the
264
+ # fields blank on reload instead of repopulating the "0"/"50" sweep defaults
265
+ discrete_freqs_given = ("fpoint" in saved_values) or ("fdump" in saved_values)
266
+ fstart_default = "" if discrete_freqs_given else "0"
267
+ fstop_default = "" if discrete_freqs_given else "50"
268
+ self.start_edit.setText(str(saved_values.get("fstart",fstart_default)))
269
+ self.stop_edit.setText(str(saved_values.get("fstop",fstop_default)))
248
270
  self.step_edit.setText(str(saved_values.get("fstep","")))
249
271
 
250
272
  float_list = saved_values.get("fpoint","")
@@ -631,7 +653,7 @@ class MeshTab(QWidget):
631
653
  self.main_layout.setAlignment(Qt.AlignTop)
632
654
 
633
655
  label_width = 250
634
- edit_width = 150
656
+ edit_width = 170
635
657
 
636
658
 
637
659
  # ---------- MESH GROUP ----------
@@ -687,7 +709,7 @@ class MeshTab(QWidget):
687
709
  self.mesh_order_box = QComboBox()
688
710
  self.mesh_order_box.setFixedWidth(edit_width)
689
711
  self.mesh_order_box.setStyleSheet(COMBO_STYLE_OPTIONAL)
690
- self.mesh_order_box.addItems(["faster, less accurate","most accurate"])
712
+ self.mesh_order_box.addItems(["faster, less accurate (N=1)","recommended (N=2)", "slower, most accurate (N=3)"])
691
713
  self.meshorder_layout.addWidget(self.mesh_order_box)
692
714
  self.mesh_order_box.setCurrentIndex(0)
693
715
  self.meshorder_layout.addStretch()
@@ -904,6 +926,22 @@ class MeshTab(QWidget):
904
926
  except:
905
927
  pass
906
928
 
929
+ # order=3 (ultra accurate) is Palace-only: Elmer has no cubic-order solver
930
+ # templates in util_elmer.write_case_and_solver_files(), so it would silently
931
+ # fall back to first-order there. Disable that option under Elmer mode, and
932
+ # fall back to the default order if it was selected when switching into Elmer.
933
+ try:
934
+ ultra_accurate_index = 2
935
+ item = self.mesh_order_box.model().item(ultra_accurate_index)
936
+ if self.MainWindow.ElmerMode:
937
+ item.setEnabled(False)
938
+ if self.mesh_order_box.currentIndex() == ultra_accurate_index:
939
+ self.mesh_order_box.setCurrentIndex(1)
940
+ else:
941
+ item.setEnabled(True)
942
+ except:
943
+ pass
944
+
907
945
 
908
946
  def save_values(self):
909
947
  try:
@@ -1088,6 +1126,12 @@ class CreateModelTab(CreateModelTabBase):
1088
1126
  # simulation run apart from a mesh-creation run (self.process is reused for both).
1089
1127
  self._process_purpose = None
1090
1128
 
1129
+ # S-parameter result viewer: appended here (not in the shared CreateModelTabBase)
1130
+ # since setupThermal has no S-parameters and must not show this button.
1131
+ self.view_results_btn = QPushButton("📈 View Results...")
1132
+ self.view_results_btn.clicked.connect(self.MainWindow.open_result_viewer)
1133
+ self.file_layout.addWidget(self.view_results_btn)
1134
+
1091
1135
  def _append_results_summary(self):
1092
1136
  # Palace-only: parse palace.json / error-indicators.csv and append a results summary
1093
1137
  # to the log. Called from on_finished() after a real simulation run.
@@ -1503,6 +1547,9 @@ class MainWindow(MainWindowBase):
1503
1547
  self.dielectrics_list = None
1504
1548
  self.metals_list = None
1505
1549
 
1550
+ # S-parameter Result Viewer window, lazily created - see open_result_viewer()
1551
+ self.result_viewer_window = None
1552
+
1506
1553
  # Do not auto-load default values at this early startup stage,
1507
1554
  # instead this is done from File menu
1508
1555
  # self.user_inputs_file = DEFAULT_SETTINGS_FILE
@@ -1558,6 +1605,27 @@ class MainWindow(MainWindowBase):
1558
1605
  self.mesh_tab.on_meshorder_changed(self.mesh_tab.mesh_order_box.currentText())
1559
1606
 
1560
1607
 
1608
+ def open_result_viewer(self):
1609
+ # local import: matplotlib/skrf are only needed once the viewer is actually
1610
+ # opened, so this keeps them off setupEM's startup path. __package__ is
1611
+ # None/"" when this module was loaded outside the setupEM package (e.g.
1612
+ # setupEM.py run directly), so relative import fails - same dual-mode
1613
+ # pattern used throughout this file/setup_common.py for sibling imports.
1614
+ if __package__ in (None, ""):
1615
+ from result_viewer import ResultViewerWindow
1616
+ else:
1617
+ from .result_viewer import ResultViewerWindow
1618
+
1619
+ if self.result_viewer_window is not None:
1620
+ self.result_viewer_window.raise_()
1621
+ self.result_viewer_window.activateWindow()
1622
+ return
1623
+
1624
+ self.result_viewer_window = ResultViewerWindow(self)
1625
+ self.result_viewer_window.destroyed.connect(lambda: setattr(self, "result_viewer_window", None))
1626
+ self.result_viewer_window.show()
1627
+
1628
+
1561
1629
  def show_version(self):
1562
1630
  setupEM_version = importlib.metadata.version("setupEM")
1563
1631
  gds2palace_version = importlib.metadata.version("gds2palace")
@@ -32,7 +32,7 @@ mesh fields, the Python model code generator bodies) is intentionally left
32
32
  in setupEM.py / setupThermal.py, not here.
33
33
  """
34
34
 
35
- import sys, os, json, pathlib, ast, webbrowser
35
+ import sys, os, json, pathlib, ast, webbrowser, io, contextlib
36
36
  import xml.etree.ElementTree as ET
37
37
  import numpy as np
38
38
  import requests
@@ -497,7 +497,7 @@ class FileInputTab(QWidget):
497
497
  self.MainWindow.saved_values["SubstrateFile"] = filename.replace('\\', '/')
498
498
  self.update_XML_description(filename)
499
499
  self.update_variable_overrides_grid(filename)
500
- self.MainWindow.read_XML() # safe if invalid filename
500
+ self.MainWindow.read_XML() # shows an error dialog and keeps prior data on invalid/unparseable XML
501
501
  # file is read when leaving the files tab
502
502
 
503
503
  def update_XML_description(self, filename):
@@ -1834,8 +1834,22 @@ class MainWindowBase(QMainWindow):
1834
1834
  def read_XML(self):
1835
1835
  filename = self.saved_values["SubstrateFile"]
1836
1836
  if pathlib.Path(filename).exists():
1837
- self.materials_list, self.dielectrics_list, self.metals_list = stackup_reader.read_substrate(
1838
- filename, variable_overrides=self.saved_values.get("variable_overrides"))
1837
+ captured_stdout = io.StringIO()
1838
+ try:
1839
+ with contextlib.redirect_stdout(captured_stdout):
1840
+ materials_list, dielectrics_list, metals_list = stackup_reader.read_substrate(
1841
+ filename, variable_overrides=self.saved_values.get("variable_overrides"))
1842
+ except (Exception, SystemExit) as e:
1843
+ # SystemExit is caught deliberately (not just Exception): the reader
1844
+ # reports hard validation failures (circular/ambiguous Reference,
1845
+ # Offset+Reference conflict, ...) via print(...); exit(1) instead of
1846
+ # raising - see the same pattern in stackupEditor.py's _refresh_preview().
1847
+ # Capture stdout so that printed ERROR text (otherwise invisible in a
1848
+ # GUI with no attached console) can be shown to the user.
1849
+ details = captured_stdout.getvalue().strip() or str(e)
1850
+ QMessageBox.critical(self, "Error", f"Could not load stackup {filename}:\n\n{details}")
1851
+ return # keep last-known-good materials_list/dielectrics_list/metals_list
1852
+ self.materials_list, self.dielectrics_list, self.metals_list = materials_list, dielectrics_list, metals_list
1839
1853
  self.update_target_layer_choices(self.metals_list)
1840
1854
  self.file_tab.update_XML_description(filename)
1841
1855
  self.file_tab.update_variable_overrides_grid(filename)
@@ -1,7 +1,27 @@
1
+ Metadata-Version: 2.4
2
+ Name: setupEM
3
+ Version: 0.4.0
4
+ Summary: Python tool for configuration of gds2palace workflow with GUI.
5
+ Author-email: Volker Muehlhaus <volker@muehlhaus.com>
6
+ License-Expression: GPL-3.0-or-later
7
+ Requires-Python: >=3.9
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ Requires-Dist: gds2palace>=0.4.0
11
+ Requires-Dist: PySide6
12
+ Requires-Dist: scipy
13
+ Requires-Dist: requests
14
+ Requires-Dist: scikit-rf
15
+ Requires-Dist: matplotlib
16
+ Requires-Dist: numpy
17
+ Requires-Dist: gdspy
18
+ Dynamic: license-file
19
+
1
20
  # Python GUI for gds2palace
2
21
 
3
22
  ## What's New
4
23
 
24
+ - A built-in **Result Viewer** (Create Model tab > View Results...) to browse and plot Touchstone S-parameter results without leaving setupEM
5
25
  - **setupThermal**, a companion app for Elmer thermal simulation, alongside setupEM
6
26
  - A graphical **Stackup XML Editor** (Tools > Edit Stackup XML...), including Variables, Reference-relative positioning, Derived Layers, and Thermal Tables
7
27
  - Input Files tab can now **override stackup Variables** (e.g. `total_thickness`) directly, without hand-editing the XML or the generated model
@@ -107,7 +127,7 @@ On this tab, you configure input files:
107
127
 
108
128
  The fields for GDSII and XML file support drag & drop or you can use the Browse... buttons.
109
129
 
110
- Some pre-processing of the layout is also defined here: You can specify a distance (in micron) which is used for **via array merging**, to speed up simulation by replacing many individual vias with one large via box. If your layout includes **polygons with holes**, you need to set the "Preprocess GDSII file" checkbox, otherwise you will get error messages during meshing.
130
+ Some pre-processing of the layout is also defined here: You can specify a distance (in micron) which is used for **via array merging**, to speed up simulation by replacing many individual vias with one large via box.
111
131
 
112
132
  <img src="https://raw.githubusercontent.com/VolkerMuehlhaus/setupEM/main/doc/png/inputfiles1.png" alt="input files" width="700">
113
133
 
@@ -186,6 +206,30 @@ To start simulation on Windows from the WSL terminal, type
186
206
 
187
207
  To **create Touchstone SnP output** from simulation results, please have a look at the scripts directory. Script "combine_snp" runs Python code "combine_extend_snp.py", which scans your directories (working directory and below) and converts simulation results to Touchstone file format. Supported input file format: Palace and Elmer S-parameter data.
188
208
 
209
+ ## Result Viewer
210
+
211
+ Once you have Touchstone SnP results, click **View Results...** on the Create Model tab to open the built-in **Result Viewer** - no need to run the standalone `plot_snp.py` script by hand.
212
+
213
+ <img src="https://raw.githubusercontent.com/VolkerMuehlhaus/setupEM/main/doc/png/resultviewer_button.png" alt="view results button" width="700">
214
+
215
+ The Result Viewer recursively scans the Target Directory for `.sNp` Touchstone files and lists them in a tree, grouped by the folder each file came from. Check individual files to overlay them, or check/uncheck a whole run's group entry to select or deselect every file below it at once.
216
+ The **Include _dc files** / **Include _deembedded files** checkboxes filter out DC-extrapolated and de-embedded variants created by `combine_extend_snp.py`, so you can start with just the raw result and bring in the others only when needed.
217
+
218
+ <img src="https://raw.githubusercontent.com/VolkerMuehlhaus/setupEM/main/doc/png/resultviewer1.png" alt="result viewer" width="750">
219
+
220
+ Pick which S-parameters to plot from the S-Parameters grid.
221
+ Checking files across several runs, or a whole run group, overlays all of them at once:
222
+
223
+ <img src="https://raw.githubusercontent.com/VolkerMuehlhaus/setupEM/main/doc/png/resultviewer2.png" alt="result viewer multi-file overlay" width="750">
224
+
225
+ For reflection parameters (S11, S22, ...), the Display panel can switch to a **Smith chart** or a **zoomed Smith chart** instead of dB+phase.
226
+
227
+ <img src="https://raw.githubusercontent.com/VolkerMuehlhaus/setupEM/main/doc/png/resultviewer3.png" alt="result viewer smith chart" width="750">
228
+
229
+ The matplotlib toolbar above the plot (pan/zoom/save as PNG) works as usual. A file with only a single simulated frequency point is marked with a dot instead of a line, since there is nothing to draw a line between.
230
+
231
+ Result Viewer can also be run standalone, without the full setupEM GUI, either directly (`python result_viewer.py [target_dir]`) or via the `resultViewer` console script installed with the package.
232
+
189
233
  ## Code
190
234
  Behind the scenes, the setupEM user interface created Python model code for gds2palace, and you can check the resulting code on the "Code" tab.
191
235
 
@@ -253,7 +297,7 @@ This project is licensed under the GNU General Public License v3.0 or later (GPL
253
297
 
254
298
  **Note:** the `setupEM` PyPI package itself requires:
255
299
 
256
- - gds2palace>=0.3.6
300
+ - gds2palace>=0.4.0
257
301
  - PySide6
258
302
  - scipy
259
303
  - requests
@@ -6,6 +6,7 @@ src/setupEM/__init__.py
6
6
  src/setupEM/__main__.py
7
7
  src/setupEM/momentum_import.py
8
8
  src/setupEM/palace_results.py
9
+ src/setupEM/result_viewer.py
9
10
  src/setupEM/setupEM.py
10
11
  src/setupEM/setupThermal.py
11
12
  src/setupEM/setup_common.py
@@ -1,4 +1,5 @@
1
1
  [console_scripts]
2
+ resultViewer = setupEM.result_viewer:main
2
3
  setupEM = setupEM.setupEM:main
3
4
  setupThermal = setupEM.setupThermal:main
4
5
  stackupEditor = setupEM.stackupEditor:main
@@ -1,4 +1,4 @@
1
- gds2palace>=0.3.6
1
+ gds2palace>=0.4.0
2
2
  PySide6
3
3
  scipy
4
4
  requests
File without changes
File without changes