setupEM 0.15.0__tar.gz → 0.16.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 (42) hide show
  1. {setupem-0.15.0/src/setupEM.egg-info → setupem-0.16.0}/PKG-INFO +21 -7
  2. {setupem-0.15.0 → setupem-0.16.0}/README_pypi.md +19 -5
  3. {setupem-0.15.0 → setupem-0.16.0}/pyproject.toml +1 -1
  4. {setupem-0.15.0 → setupem-0.16.0}/src/setupEM/__init__.py +1 -1
  5. setupem-0.16.0/src/setupEM/config_update.py +179 -0
  6. {setupem-0.15.0 → setupem-0.16.0}/src/setupEM/script_model.py +1 -1
  7. {setupem-0.15.0 → setupem-0.16.0}/src/setupEM/setupEM.py +37 -4
  8. {setupem-0.15.0 → setupem-0.16.0}/src/setupEM/setup_common.py +168 -6
  9. {setupem-0.15.0 → setupem-0.16.0/src/setupEM.egg-info}/PKG-INFO +21 -7
  10. {setupem-0.15.0 → setupem-0.16.0}/src/setupEM.egg-info/SOURCES.txt +3 -0
  11. {setupem-0.15.0 → setupem-0.16.0}/src/setupEM.egg-info/requires.txt +1 -1
  12. setupem-0.16.0/tests/test_config_update.py +83 -0
  13. setupem-0.16.0/tests/test_config_update_gui.py +169 -0
  14. {setupem-0.15.0 → setupem-0.16.0}/LICENSE +0 -0
  15. {setupem-0.15.0 → setupem-0.16.0}/README.md +0 -0
  16. {setupem-0.15.0 → setupem-0.16.0}/setup.cfg +0 -0
  17. {setupem-0.15.0 → setupem-0.16.0}/src/setupEM/__main__.py +0 -0
  18. {setupem-0.15.0 → setupem-0.16.0}/src/setupEM/data/SG13CMOS5L_200um.xml +0 -0
  19. {setupem-0.15.0 → setupem-0.16.0}/src/setupEM/data/SG13G2_100um.xml +0 -0
  20. {setupem-0.15.0 → setupem-0.16.0}/src/setupEM/data/SG13G2_200um.xml +0 -0
  21. {setupem-0.15.0 → setupem-0.16.0}/src/setupEM/data/SG13G2_FEM_200um.xml +0 -0
  22. {setupem-0.15.0 → setupem-0.16.0}/src/setupEM/data/SG13G2_FEM_200um_passi3D.xml +0 -0
  23. {setupem-0.15.0 → setupem-0.16.0}/src/setupEM/data/SG13G2_nosub.xml +0 -0
  24. {setupem-0.15.0 → setupem-0.16.0}/src/setupEM/field_viewer.py +0 -0
  25. {setupem-0.15.0 → setupem-0.16.0}/src/setupEM/gds_hierarchy_scan.py +0 -0
  26. {setupem-0.15.0 → setupem-0.16.0}/src/setupEM/layout_preview.py +0 -0
  27. {setupem-0.15.0 → setupem-0.16.0}/src/setupEM/momentum_import.py +0 -0
  28. {setupem-0.15.0 → setupem-0.16.0}/src/setupEM/palace_results.py +0 -0
  29. {setupem-0.15.0 → setupem-0.16.0}/src/setupEM/result_viewer.py +0 -0
  30. {setupem-0.15.0 → setupem-0.16.0}/src/setupEM/run_with_overrides.py +0 -0
  31. {setupem-0.15.0 → setupem-0.16.0}/src/setupEM/script_probe.py +0 -0
  32. {setupem-0.15.0 → setupem-0.16.0}/src/setupEM/setupThermal.py +0 -0
  33. {setupem-0.15.0 → setupem-0.16.0}/src/setupEM/simplify_gds.py +0 -0
  34. {setupem-0.15.0 → setupem-0.16.0}/src/setupEM/stackupEditor.py +0 -0
  35. {setupem-0.15.0 → setupem-0.16.0}/src/setupEM/stackup_writer.py +0 -0
  36. {setupem-0.15.0 → setupem-0.16.0}/src/setupEM/thermal_results.py +0 -0
  37. {setupem-0.15.0 → setupem-0.16.0}/src/setupEM.egg-info/dependency_links.txt +0 -0
  38. {setupem-0.15.0 → setupem-0.16.0}/src/setupEM.egg-info/entry_points.txt +0 -0
  39. {setupem-0.15.0 → setupem-0.16.0}/src/setupEM.egg-info/top_level.txt +0 -0
  40. {setupem-0.15.0 → setupem-0.16.0}/tests/test_run_with_overrides.py +0 -0
  41. {setupem-0.15.0 → setupem-0.16.0}/tests/test_script_model.py +0 -0
  42. {setupem-0.15.0 → setupem-0.16.0}/tests/test_script_probe.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: setupEM
3
- Version: 0.15.0
3
+ Version: 0.16.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
@@ -10,7 +10,7 @@ Project-URL: Changelog, https://github.com/VolkerMuehlhaus/setupEM/blob/main/doc
10
10
  Requires-Python: >=3.9
11
11
  Description-Content-Type: text/markdown
12
12
  License-File: LICENSE
13
- Requires-Dist: gds2palace>=0.9.0
13
+ Requires-Dist: gds2palace>=0.9.3
14
14
  Requires-Dist: gds_prepare_for_EM>=1.6.0
15
15
  Requires-Dist: PySide6
16
16
  Requires-Dist: shiboken6
@@ -40,6 +40,24 @@ https://github.com/VolkerMuehlhaus/setupEM
40
40
 
41
41
  ## Recent changes
42
42
 
43
+ # What's New - October 31, 2026 (setupEM 0.16.0)
44
+
45
+ **After a new export from the layout tool** (setupEM): a layout tool such as the KLayout plug-in klayout_em_export writes the GDSII file and a `.simcfg`, and writes both again when the layout changes. The settings you made in setupEM since are only in your model script, because Create Model writes the script, not the `.simcfg`. setupEM now takes from such a `.simcfg` only the ports, GDSII file, stackup, cell and purpose, and keeps everything else as in the script:
46
+ - Opening a `.simcfg` when a model script for the same GDSII file exists next to it asks whether to **Continue with the script** (edit it in place, ports and layout settings from the `.simcfg`) or **Start from the config**.
47
+ - Loading a `.simcfg` while editing a script in place lists the ports and layout settings that differ and asks whether to **Update the script from it** or **Open it as a new model**. Before, loading it ended editing in place and replaced all settings.
48
+ - While editing a script in place, the bar below the menu points out a newer `.simcfg` of the same GDSII file with other ports or layout settings, with **Update from it**.
49
+
50
+ **Load Config, Import from \*.py model and Edit \*.py model in place** start in the folder of the model you are working on (the config or script loaded last, else the GDSII file). Before, they started in the folder setupEM was launched from, e.g. KLayout's when the KLayout plug-in opened setupEM.
51
+
52
+ setupEM 0.16.0 needs gds2palace 0.9.3 or later: a layout with several shapes on one port layer (e.g. a transistor terminal on several fingers, from the KLayout plug-in) makes one port with several elements there; older gds2palace versions used only the last shape.
53
+
54
+ **Fixes:**
55
+ - `scripts/combine_extend_snp.py` is the same as gds2palace's again. It had missed three fixes made there in September: the port impedance lookup for AMR iterations, the missing `.sNp` extension for a model name that contains a dot, and Touchstone output after an interrupted Palace run.
56
+ - On Linux with GNOME (Wayland), the mouse cursor no longer keeps spinning for about 10 seconds after setupEM starts. setupEM briefly opened three invisible windows while building the Input Files tab, and GNOME waited for them to finish starting.
57
+ - Via ports with direction `-Z` keep their from and to layers. Before, loading a `.simcfg` dropped them, and Create Model wrote such a port as an in-plane port.
58
+ - An incomplete port (direction and layers that don't fit, e.g. from a `.simcfg` written by another tool) gives a warning in the Ports tab. Before, gds2palace exited and setupEM closed. A lowercase `z` counts as a via port too.
59
+ - `combine_extend_snp` (Touchstone output after a Palace run) handles ports with several elements (several shapes on one port layer, gds2palace 0.9.3): one Z0 per port in the option line, and the elements' port inductances combined in parallel for de-embedding. Before, the last element overwrote the others.
60
+
43
61
  # What's New - October 29, 2026
44
62
 
45
63
  **File > Edit \*.py model in place** (new, setupEM and setupThermal): edits a gds2palace model script directly, e.g. one from the gds2palace MCP server, an example or your own code. Create Model and **File > Save** write only the values you change; the script's loops, comments and custom code stay. Settings the script computes itself are greyed out. A script that builds several models (a parameter sweep) lists them on the Create Model tab, and Start Simulation runs the ticked ones. After importing a script that setupEM didn't write, a bar below the menu offers **Edit in place instead**.
@@ -56,17 +74,13 @@ Import and edit in place now read a stackup given as `utilities.get_stackup_path
56
74
 
57
75
  Fix: with **Merge via arrays** (and no fill removal or merging per layer), the simplified file still contained the original subcells as extra top-level cells. Without a cell name, gds2palace reads the first top-level cell, which could be one of them, e.g. a pad cell instead of the design. These left-over cells are now removed.
58
76
 
59
- # What's New - October 19, 2026
60
-
61
- **Stackup Editor, ADS Momentum (*.ltd) import:** metals with an EXPAND thickness operation now make the dielectric next to them thicker, as in ADS, instead of reaching into the dielectric below. Before, a thick EXPAND DOWN metal (e.g. MET1/MET2 in a GaAs stackup) overlapped the layers underneath it, and a via could end up with zero height. Files where long lines are wrapped or END_... markers follow the last entry on the same line now import too; before, they failed with "No usable dielectric slabs".
62
-
63
77
  Full history: [CHANGES.md](https://github.com/VolkerMuehlhaus/setupEM/blob/main/doc/CHANGES.md)
64
78
 
65
79
  ---
66
80
 
67
81
  **Note:** the `setupEM` PyPI package itself requires:
68
82
 
69
- - gds2palace>=0.9.0
83
+ - gds2palace>=0.9.3
70
84
  - gds_prepare_for_EM>=1.6.0
71
85
  - PySide6
72
86
  - shiboken6
@@ -11,6 +11,24 @@ https://github.com/VolkerMuehlhaus/setupEM
11
11
 
12
12
  ## Recent changes
13
13
 
14
+ # What's New - October 31, 2026 (setupEM 0.16.0)
15
+
16
+ **After a new export from the layout tool** (setupEM): a layout tool such as the KLayout plug-in klayout_em_export writes the GDSII file and a `.simcfg`, and writes both again when the layout changes. The settings you made in setupEM since are only in your model script, because Create Model writes the script, not the `.simcfg`. setupEM now takes from such a `.simcfg` only the ports, GDSII file, stackup, cell and purpose, and keeps everything else as in the script:
17
+ - Opening a `.simcfg` when a model script for the same GDSII file exists next to it asks whether to **Continue with the script** (edit it in place, ports and layout settings from the `.simcfg`) or **Start from the config**.
18
+ - Loading a `.simcfg` while editing a script in place lists the ports and layout settings that differ and asks whether to **Update the script from it** or **Open it as a new model**. Before, loading it ended editing in place and replaced all settings.
19
+ - While editing a script in place, the bar below the menu points out a newer `.simcfg` of the same GDSII file with other ports or layout settings, with **Update from it**.
20
+
21
+ **Load Config, Import from \*.py model and Edit \*.py model in place** start in the folder of the model you are working on (the config or script loaded last, else the GDSII file). Before, they started in the folder setupEM was launched from, e.g. KLayout's when the KLayout plug-in opened setupEM.
22
+
23
+ setupEM 0.16.0 needs gds2palace 0.9.3 or later: a layout with several shapes on one port layer (e.g. a transistor terminal on several fingers, from the KLayout plug-in) makes one port with several elements there; older gds2palace versions used only the last shape.
24
+
25
+ **Fixes:**
26
+ - `scripts/combine_extend_snp.py` is the same as gds2palace's again. It had missed three fixes made there in September: the port impedance lookup for AMR iterations, the missing `.sNp` extension for a model name that contains a dot, and Touchstone output after an interrupted Palace run.
27
+ - On Linux with GNOME (Wayland), the mouse cursor no longer keeps spinning for about 10 seconds after setupEM starts. setupEM briefly opened three invisible windows while building the Input Files tab, and GNOME waited for them to finish starting.
28
+ - Via ports with direction `-Z` keep their from and to layers. Before, loading a `.simcfg` dropped them, and Create Model wrote such a port as an in-plane port.
29
+ - An incomplete port (direction and layers that don't fit, e.g. from a `.simcfg` written by another tool) gives a warning in the Ports tab. Before, gds2palace exited and setupEM closed. A lowercase `z` counts as a via port too.
30
+ - `combine_extend_snp` (Touchstone output after a Palace run) handles ports with several elements (several shapes on one port layer, gds2palace 0.9.3): one Z0 per port in the option line, and the elements' port inductances combined in parallel for de-embedding. Before, the last element overwrote the others.
31
+
14
32
  # What's New - October 29, 2026
15
33
 
16
34
  **File > Edit \*.py model in place** (new, setupEM and setupThermal): edits a gds2palace model script directly, e.g. one from the gds2palace MCP server, an example or your own code. Create Model and **File > Save** write only the values you change; the script's loops, comments and custom code stay. Settings the script computes itself are greyed out. A script that builds several models (a parameter sweep) lists them on the Create Model tab, and Start Simulation runs the ticked ones. After importing a script that setupEM didn't write, a bar below the menu offers **Edit in place instead**.
@@ -27,17 +45,13 @@ Import and edit in place now read a stackup given as `utilities.get_stackup_path
27
45
 
28
46
  Fix: with **Merge via arrays** (and no fill removal or merging per layer), the simplified file still contained the original subcells as extra top-level cells. Without a cell name, gds2palace reads the first top-level cell, which could be one of them, e.g. a pad cell instead of the design. These left-over cells are now removed.
29
47
 
30
- # What's New - October 19, 2026
31
-
32
- **Stackup Editor, ADS Momentum (*.ltd) import:** metals with an EXPAND thickness operation now make the dielectric next to them thicker, as in ADS, instead of reaching into the dielectric below. Before, a thick EXPAND DOWN metal (e.g. MET1/MET2 in a GaAs stackup) overlapped the layers underneath it, and a via could end up with zero height. Files where long lines are wrapped or END_... markers follow the last entry on the same line now import too; before, they failed with "No usable dielectric slabs".
33
-
34
48
  Full history: [CHANGES.md](https://github.com/VolkerMuehlhaus/setupEM/blob/main/doc/CHANGES.md)
35
49
 
36
50
  ---
37
51
 
38
52
  **Note:** the `setupEM` PyPI package itself requires:
39
53
 
40
- - gds2palace>=0.9.0
54
+ - gds2palace>=0.9.3
41
55
  - gds_prepare_for_EM>=1.6.0
42
56
  - PySide6
43
57
  - shiboken6
@@ -12,7 +12,7 @@ authors = [
12
12
  { name = "Volker Muehlhaus", email = "volker@muehlhaus.com" }
13
13
  ]
14
14
  dependencies = [
15
- "gds2palace>=0.9.0",
15
+ "gds2palace>=0.9.3",
16
16
  "gds_prepare_for_EM>=1.6.0",
17
17
  "PySide6",
18
18
  "shiboken6",
@@ -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.15.0"
27
+ __version__ = "0.16.0"
@@ -0,0 +1,179 @@
1
+ ########################################################################
2
+ #
3
+ # Copyright 2025 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
+ """Update a script edited in place from a settings file (*.simcfg) of the same layout.
20
+
21
+ A layout tool such as the KLayout plug-in klayout_em_export writes the GDS file and a *.simcfg next to it. When the
22
+ layout changes, it exports again: same GDS file name, new ports (numbered 1..N without gaps, so they can shift).
23
+ A model script made before, maybe edited by hand, still has the old ports. setupEM's edit-in-place mode can update
24
+ it: the settings that belong to the layout (LAYOUT_KEYS and the ports) come from the *.simcfg, everything else
25
+ (frequencies, mesh, the script's own code) stays.
26
+
27
+ No Qt in here, so the module can be tested on its own.
28
+ """
29
+
30
+ import ast
31
+ import glob
32
+ import json
33
+ import os
34
+
35
+ __all__ = ["LAYOUT_KEYS", "port_differences", "layout_differences", "newer_configs", "model_scripts"]
36
+
37
+ # saved_values that belong to the layout export, besides the ports
38
+ LAYOUT_KEYS = ("GdsFile", "SubstrateFile", "cellname", "purpose")
39
+ LAYOUT_LABELS = {"GdsFile": "GDSII file", "SubstrateFile": "stackup", "cellname": "cell", "purpose": "purpose"}
40
+
41
+ _PORT_KEYS = ("source_layernum", "direction", "from_layername", "to_layername", "target_layername")
42
+
43
+
44
+ def _port_signature(port):
45
+ """(source layer, direction, from, to, target) of a port dict; Z0 and excitation are not part of it."""
46
+ values = []
47
+ for key in _PORT_KEYS:
48
+ value = port.get(key)
49
+ if key == "source_layernum" and value not in (None, ""):
50
+ value = int(value)
51
+ elif key == "direction" and isinstance(value, str):
52
+ value = value.upper()
53
+ values.append(value or None)
54
+ layer, direction, frm, to, target = values
55
+ if direction and "Z" in direction:
56
+ target = None
57
+ else:
58
+ frm = to = None
59
+ return layer, direction, frm, to, target
60
+
61
+
62
+ def _describe(signature):
63
+ layer, direction, frm, to, target = signature
64
+ if direction and "Z" in direction:
65
+ return f"via {frm} -> {to} ({direction}) on layer {layer}"
66
+ return f"in-plane {direction} on {target}, layer {layer}"
67
+
68
+
69
+ def port_differences(old_ports, new_ports):
70
+ """Readable differences from old_ports to new_ports (lists of port dicts), [] if they match. Compared per port
71
+ number: layer, direction, from/to/target layer; not Z0 or excitation."""
72
+ old = {int(p["portnumber"]): _port_signature(p) for p in old_ports if p.get("portnumber") not in (None, "")}
73
+ new = {int(p["portnumber"]): _port_signature(p) for p in new_ports if p.get("portnumber") not in (None, "")}
74
+ out = []
75
+ for number in sorted(set(old) | set(new)):
76
+ if number not in new:
77
+ out.append(f"port {number}: removed")
78
+ elif number not in old:
79
+ out.append(f"port {number}: new, {_describe(new[number])}")
80
+ elif old[number] != new[number]:
81
+ out.append(f"port {number}: was {_describe(old[number])}, now {_describe(new[number])}")
82
+ return out
83
+
84
+
85
+ def _same_path(a, b, base_dir=""):
86
+ if not a or not b:
87
+ return a == b
88
+ norm = lambda p: os.path.normcase(os.path.abspath(os.path.join(base_dir, p)))
89
+ return norm(a) == norm(b)
90
+
91
+
92
+ def layout_differences(old_values, new_values, new_base_dir=""):
93
+ """Readable differences of the LAYOUT_KEYS from old_values to new_values (saved_values dicts); file paths in
94
+ new_values relative to new_base_dir."""
95
+ out = []
96
+ for key in LAYOUT_KEYS:
97
+ if key not in new_values:
98
+ continue
99
+ old, new = old_values.get(key), new_values.get(key)
100
+ if key in ("GdsFile", "SubstrateFile"):
101
+ if not _same_path(old, new, new_base_dir):
102
+ out.append(f"{LAYOUT_LABELS[key]}: {os.path.basename(old or '-')} -> {os.path.basename(new or '-')}")
103
+ elif key == "purpose":
104
+ if [int(p) for p in old or []] != [int(p) for p in new or []]:
105
+ out.append(f"{LAYOUT_LABELS[key]}: {old} -> {new}")
106
+ elif old != new:
107
+ out.append(f"{LAYOUT_LABELS[key]}: {old} -> {new}")
108
+ return out
109
+
110
+
111
+ def newer_configs(script_path, gds_path, folders, suffix=".simcfg", application="setupEM"):
112
+ """[(path, data)] of the settings files in folders that belong to gds_path (their GdsFile is that file) and are
113
+ newer than script_path, newest first."""
114
+ try:
115
+ script_time = os.path.getmtime(script_path)
116
+ except OSError:
117
+ return []
118
+ seen, out = set(), []
119
+ for folder in folders:
120
+ if not folder or not os.path.isdir(folder):
121
+ continue
122
+ for path in glob.glob(os.path.join(folder, "*" + suffix)):
123
+ key = os.path.normcase(os.path.abspath(path))
124
+ if key in seen:
125
+ continue
126
+ seen.add(key)
127
+ try:
128
+ if os.path.getmtime(path) <= script_time:
129
+ continue
130
+ with open(path, encoding="utf-8") as f:
131
+ data = json.load(f)
132
+ except (OSError, ValueError):
133
+ continue
134
+ if not isinstance(data, dict) or data.get("application") != application:
135
+ continue
136
+ gds = (data.get("saved_values") or {}).get("GdsFile")
137
+ if gds and _same_path(gds, gds_path, os.path.dirname(path)):
138
+ out.append((path, data))
139
+ out.sort(key=lambda item: os.path.getmtime(item[0]), reverse=True)
140
+ return out
141
+
142
+
143
+ def _reads_gds(script_path, gds_path):
144
+ """True if one of the script's string literals, taken relative to the script's folder, is gds_path (the script
145
+ is read with ast, never run)."""
146
+ try:
147
+ with open(script_path, encoding="utf-8", errors="replace") as f:
148
+ text = f.read()
149
+ except OSError:
150
+ return False
151
+ if os.path.basename(gds_path) not in text: # cheap test before parsing
152
+ return False
153
+ try:
154
+ tree = ast.parse(text)
155
+ except (SyntaxError, ValueError):
156
+ return False
157
+ folder = os.path.dirname(os.path.abspath(script_path))
158
+ return any(isinstance(n, ast.Constant) and isinstance(n.value, str) and n.value.lower().endswith(".gds")
159
+ and _same_path(n.value, gds_path, folder) for n in ast.walk(tree))
160
+
161
+
162
+ def model_scripts(gds_path, folders):
163
+ """Model scripts (*.py) in folders that read gds_path, newest first. Their age says nothing against a settings
164
+ file of the same layout: a layout tool writes that file again on every export, also after the script was made,
165
+ while the settings made in setupEM since are only in the script (Create Model writes the script, not the
166
+ settings file)."""
167
+ seen, out = set(), []
168
+ for folder in folders:
169
+ if not folder or not os.path.isdir(folder):
170
+ continue
171
+ for path in glob.glob(os.path.join(folder, "*.py")):
172
+ key = os.path.normcase(os.path.abspath(path))
173
+ if key in seen:
174
+ continue
175
+ seen.add(key)
176
+ if _reads_gds(path, gds_path):
177
+ out.append(path)
178
+ out.sort(key=os.path.getmtime, reverse=True)
179
+ return out
@@ -830,7 +830,7 @@ def _port_values(port):
830
830
  "port_Z0": _number(port.get("port_Z0", 50)),
831
831
  "source_layernum": _number(int(port["source_layernum"])),
832
832
  }
833
- if str(port.get("direction", "")).upper() == "Z":
833
+ if "Z" in str(port.get("direction", "")).upper():
834
834
  values["from_layername"] = repr(port.get("from_layername", ""))
835
835
  values["to_layername"] = repr(port.get("to_layername", ""))
836
836
  else:
@@ -61,10 +61,11 @@ if __package__ in (None, ""):
61
61
  eval_simple_python_expression, collect_module_level_constants,
62
62
  find_paraview_exe, FILL_FACTOR_CORRECTION_SOLVERS, PALACE_LINEAR_SOLVER_SETTINGS,
63
63
  GDS2PALACE_CONDUCTOR_MODELS, MODEL_KIND_EM,
64
- protected_script_message,
64
+ protected_script_message, resolve_missing_file_paths,
65
65
  )
66
66
  from palace_results import build_results_summary, find_output_dir, find_paraview_files
67
67
  from script_model import ScriptModel
68
+ from config_update import LAYOUT_KEYS, layout_differences, port_differences
68
69
  else:
69
70
  from .setup_common import (
70
71
  EDIT_STYLE_OPTIONAL, EDIT_STYLE_REQUIRED, COMBO_STYLE_REQUIRED, COMBO_STYLE_OPTIONAL,
@@ -77,10 +78,11 @@ else:
77
78
  eval_simple_python_expression, collect_module_level_constants,
78
79
  find_paraview_exe, FILL_FACTOR_CORRECTION_SOLVERS, PALACE_LINEAR_SOLVER_SETTINGS,
79
80
  GDS2PALACE_CONDUCTOR_MODELS, MODEL_KIND_EM,
80
- protected_script_message,
81
+ protected_script_message, resolve_missing_file_paths,
81
82
  )
82
83
  from .palace_results import build_results_summary, find_output_dir, find_paraview_files
83
84
  from .script_model import ScriptModel
85
+ from .config_update import LAYOUT_KEYS, layout_differences, port_differences
84
86
 
85
87
 
86
88
  '''
@@ -778,8 +780,18 @@ class PortsTab(QWidget):
778
780
  except Exception:
779
781
  QMessageBox.warning(self, "Error", "Invalid input for port " + str(portnumber))
780
782
  return False
783
+ # gds2palace's simulation_port() exits the whole program on a direction that does not
784
+ # match its layers, so check here and warn instead (e.g. an incomplete port from a .simcfg)
785
+ via = "Z" in direction.upper()
786
+ if direction.strip().upper() not in ("X", "Y", "Z", "-X", "-Y", "-Z") or \
787
+ not ((from_name and to_name) if via else target_name):
788
+ QMessageBox.warning(self, "Error",
789
+ f"Port {portnumber} is incomplete: direction X, Y, -X or -Y needs a "
790
+ "target layer (in-plane port), Z or -Z needs a from and a to layer "
791
+ "(via port).")
792
+ return False
781
793
  # create port
782
- if "Z" in direction:
794
+ if via:
783
795
  # via port
784
796
  simulation_ports.add_port(simulation_setup.simulation_port(portnumber=portnumber,
785
797
  voltage=voltage,
@@ -843,7 +855,7 @@ class PortsTab(QWidget):
843
855
  self.portslist.setItem(portnum-1, 2, QTableWidgetItem(str(port.get("source_layernum",""))))
844
856
 
845
857
  direction = str(port.get("direction","")).upper()
846
- if direction == 'Z':
858
+ if "Z" in direction:
847
859
  self.portslist.setItem(portnum-1, 4, QTableWidgetItem(str(port.get("from_layername",""))))
848
860
  self.portslist.setItem(portnum-1, 5, QTableWidgetItem(str(port.get("to_layername",""))))
849
861
  self.portslist.setItem(portnum-1, 3, QTableWidgetItem(""))
@@ -3813,6 +3825,27 @@ class MainWindow(MainWindowBase):
3813
3825
  def preserve_create_call(self):
3814
3826
  return "create_elmer" if self.ElmerMode else "create_palace"
3815
3827
 
3828
+ # ---------- Update a script edited in place from a *.simcfg (see MainWindowBase) ----------
3829
+ def config_update_supported(self):
3830
+ return True
3831
+
3832
+ def config_update_differences(self, data, base_dir):
3833
+ return (port_differences(self.preserve_objects(), data.get("ports", []))
3834
+ + layout_differences(self.saved_values, data.get("saved_values") or {}, base_dir))
3835
+
3836
+ def apply_config_update(self, data, base_dir):
3837
+ # ports, GDS file, stackup, cell and purpose from the settings file; everything else stays
3838
+ saved = data.get("saved_values") or {}
3839
+ layout = {key: saved[key] for key in LAYOUT_KEYS if key in saved}
3840
+ for key in ("GdsFile", "SubstrateFile"):
3841
+ if layout.get(key):
3842
+ layout[key] = os.path.join(base_dir, layout[key]).replace("\\", "/")
3843
+ resolve_missing_file_paths(layout, base_dir)
3844
+ self.saved_values.update(layout)
3845
+ self.ports_tab.update_port_from_import(data.get("ports", []))
3846
+ self.load_all_tabs()
3847
+ self.save_all_tabs()
3848
+
3816
3849
  def preserve_extra(self, baseline_values, current_values):
3817
3850
  # fdump_enabled is Elmer's GUI-only field dump checkbox; in Elmer mode it
3818
3851
  # decides settings['fdump'] (see create_model_text())
@@ -63,10 +63,12 @@ if __package__ in (None, ""):
63
63
  import gds_hierarchy_scan
64
64
  from script_model import eval_simple_python_expression, ScriptModel, patch_script, LEGACY_SETTINGS
65
65
  from script_probe import probe_script
66
+ from config_update import model_scripts, newer_configs
66
67
  else:
67
68
  from . import gds_hierarchy_scan
68
69
  from .script_model import eval_simple_python_expression, ScriptModel, patch_script, LEGACY_SETTINGS
69
70
  from .script_probe import probe_script
71
+ from .config_update import model_scripts, newer_configs
70
72
 
71
73
  # ------------------------------------------------------------------
72
74
  # gds2palace feature-compatibility detection: an older gds2palace (e.g. a stale
@@ -725,12 +727,16 @@ class FileInputTab(QWidget):
725
727
 
726
728
  # settings['fill_factor_correction'], shown only for solvers where the installed
727
729
  # gds2palace supports it, see show_fill_factor_correction()
730
+ # Created with this tab as parent: show_fill_factor_correction() below calls setVisible() before the
731
+ # layout is installed, and a parentless widget made visible becomes a top-level window for a moment.
732
+ # On Wayland each such window requests an activation token that is never used, and GNOME shows a
733
+ # spinning cursor until it times out (about 10 s after setupEM starts).
728
734
  self.fill_factor_layout = QHBoxLayout()
729
- self.fill_factor_label1 = QLabel("Correction for via array cross section is ")
735
+ self.fill_factor_label1 = QLabel("Correction for via array cross section is ", self)
730
736
  # minimum instead of fixed width: lines up with the rows above, but grows rather
731
737
  # than truncating if the text is wider at a larger font or DPI setting
732
738
  self.fill_factor_label1.setMinimumWidth(left_label_width)
733
- self.fill_factor_box = QComboBox()
739
+ self.fill_factor_box = QComboBox(self)
734
740
  self.fill_factor_box.setStyleSheet(COMBO_STYLE_OPTIONAL)
735
741
  self.fill_factor_box.addItems(["enabled", "disabled"])
736
742
  self.fill_factor_box.setCurrentText("disabled")
@@ -740,7 +746,7 @@ class FileInputTab(QWidget):
740
746
  "each merged via polygon (heat conductivity for thermal models) is multiplied by\n"
741
747
  "its fill factor (original via area / merged polygon area). Only has an effect\n"
742
748
  "when via array merging is enabled.")
743
- self.fill_factor_label2 = QLabel(" (conductivity × via fill factor)")
749
+ self.fill_factor_label2 = QLabel(" (conductivity × via fill factor)", self)
744
750
  self.fill_factor_layout.addWidget(self.fill_factor_label1)
745
751
  self.fill_factor_layout.addWidget(self.fill_factor_box)
746
752
  self.fill_factor_layout.addWidget(self.fill_factor_label2)
@@ -4480,6 +4486,8 @@ class MainWindowBase(QMainWindow):
4480
4486
  elif self.script_model is not None:
4481
4487
  # keep the unsaved-changes * in the title current
4482
4488
  self.script_has_unsaved_changes()
4489
+ # a newer settings file of the same layout (e.g. after an export from KLayout): offer the update
4490
+ self.refresh_config_notice()
4483
4491
 
4484
4492
  # Save model code only when model tab active
4485
4493
  self.export_model_action.setEnabled(index == modeleditor_index)
@@ -4550,8 +4558,20 @@ class MainWindowBase(QMainWindow):
4550
4558
  return {}
4551
4559
  return {}
4552
4560
 
4561
+ def _open_dialog_folder(self):
4562
+ """Where Load Config / Import / Edit in place start: the folder of the script edited in place or of the
4563
+ settings file loaded last, else of the GDSII file; '' (the process' working folder) if there is none.
4564
+ Started from a layout tool, that working folder is the tool's, not the one of its export."""
4565
+ candidates = [getattr(getattr(self, "script_model", None), "path", None),
4566
+ getattr(self, "user_inputs_file", None), self.saved_values.get("GdsFile")]
4567
+ for path in candidates:
4568
+ if path and os.path.isdir(os.path.dirname(os.path.abspath(path))):
4569
+ return os.path.dirname(os.path.abspath(path))
4570
+ return ""
4571
+
4553
4572
  def load_configuration_dialog(self):
4554
- file_path, _ = QFileDialog.getOpenFileName(self, "Select Config File", filter=f"*.{self.CONFIG_SUFFIX};;Python model code *.py")
4573
+ file_path, _ = QFileDialog.getOpenFileName(self, "Select Config File", self._open_dialog_folder(),
4574
+ filter=f"*.{self.CONFIG_SUFFIX};;Python model code *.py")
4555
4575
  # we can load JSON or Python models, decide which suffix we have
4556
4576
  if file_path:
4557
4577
  self.open_file(file_path)
@@ -4596,6 +4616,27 @@ class MainWindowBase(QMainWindow):
4596
4616
  data.get("application", "") in MODEL_KIND_APPS.values():
4597
4617
  self._warn_wrong_app_file(file_path, data["application"])
4598
4618
  return
4619
+ if data.get("application", "") == self.APP_NAME and self.config_update_supported():
4620
+ if self.script_model is not None:
4621
+ # a script is edited in place: update it from this config, or open the config instead
4622
+ choice = self._ask_update_script_from_config(file_path, data)
4623
+ if choice == "cancel":
4624
+ return
4625
+ if choice == "update":
4626
+ self._update_script_from_config(file_path, data)
4627
+ return
4628
+ else:
4629
+ # a model script for the same layout has the settings made in setupEM since
4630
+ script = self._model_script_for_config(file_path, data)
4631
+ choice = self._ask_continue_with_script(file_path, script) if script else "config"
4632
+ if choice == "cancel":
4633
+ return
4634
+ if choice == "script":
4635
+ self.load_configuration_from_file(script, in_place=True, ask_unsaved=False)
4636
+ if self.script_model is not None:
4637
+ self.hide_info_bar()
4638
+ self._update_script_from_config(file_path, data)
4639
+ return
4599
4640
  leave_current_script()
4600
4641
  self.user_inputs_file = file_path
4601
4642
  if data.get("application", "") == self.APP_NAME:
@@ -4894,6 +4935,8 @@ class MainWindowBase(QMainWindow):
4894
4935
  if path_messages or not_editable:
4895
4936
  status += " - see the log on the Create Model tab"
4896
4937
  self.statusBar().showMessage(status, 15000)
4938
+ if in_place:
4939
+ self.refresh_config_notice()
4897
4940
 
4898
4941
  else:
4899
4942
  QMessageBox.information(self, "Error", f"Could not load file {file_path}")
@@ -4980,6 +5023,123 @@ class MainWindowBase(QMainWindow):
4980
5023
  "objects": copy.deepcopy(self.preserve_objects()),
4981
5024
  "create_call": self.preserve_create_call()}
4982
5025
 
5026
+ # ---------- Update a script edited in place from a settings file of the same layout ----------
5027
+ # A layout tool (e.g. the KLayout plug-in klayout_em_export) writes the GDS file and a settings file next to
5028
+ # it, and writes both again when the layout changes. The model script holds everything set later (Create Model
5029
+ # writes the script, not the settings file). So the settings that belong to the layout (ports, GDS file,
5030
+ # stackup, cell, purpose) come from the settings file, everything else stays as in the script. See
5031
+ # config_update.py.
5032
+
5033
+ def config_update_supported(self):
5034
+ """Hook: True if this app can update a script edited in place from its settings file."""
5035
+ return False
5036
+
5037
+ def config_update_differences(self, data, base_dir):
5038
+ """Hook: readable differences between the current settings and the layout part of data (a loaded
5039
+ settings file from base_dir), [] if none. The caller saves the tabs first if needed."""
5040
+ return []
5041
+
5042
+ def apply_config_update(self, data, base_dir):
5043
+ """Hook: take the layout part of data (ports, GDS file, stackup, cell, purpose) into the tabs."""
5044
+
5045
+ def _update_script_from_config(self, file_path, data):
5046
+ """Take the layout part of a settings file into the script edited in place (written on Create Model or
5047
+ Save, like every change in edit-in-place mode)."""
5048
+ self.apply_config_update(data, os.path.dirname(os.path.abspath(file_path)))
5049
+ dirty = self.script_has_unsaved_changes()
5050
+ if self.tabs_widget.currentWidget() is self.modeleditor_tab:
5051
+ self.show_preserve_preview(self.modeleditor_tab.model_edit)
5052
+ self.show_info_bar(f"Ports and layout settings taken from {os.path.basename(file_path)}. "
5053
+ + ("Create Model or File > Save writes them into the script." if dirty
5054
+ else "The script already had them."))
5055
+
5056
+ def _ask_update_script_from_config(self, file_path, data):
5057
+ """Loading a settings file while a script is edited in place: "update", "new" or "cancel"."""
5058
+ self.save_all_tabs()
5059
+ differences = self.config_update_differences(data, os.path.dirname(os.path.abspath(file_path)))
5060
+ name, script = os.path.basename(file_path), os.path.basename(self.script_model.path)
5061
+ if differences:
5062
+ found = (f"{name} has other ports or layout settings than the script:\n "
5063
+ + "\n ".join(differences[:8]) + ("\n ..." if len(differences) > 8 else ""))
5064
+ else:
5065
+ found = f"{name} has the same ports and layout settings as the script."
5066
+ box = QMessageBox(self)
5067
+ box.setWindowTitle("Load Config")
5068
+ box.setIcon(QMessageBox.Question)
5069
+ box.setText(f"You are editing {script} in place.\n\n{found}\n\n"
5070
+ "Update the script from it: ports, GDSII file, stackup, cell and purpose come from the "
5071
+ "config, everything else (frequencies, mesh, your own code) stays.\n\n"
5072
+ "Open it as a new model: leave the script, the config's settings replace all.")
5073
+ update = box.addButton("Update the script from it", QMessageBox.AcceptRole)
5074
+ new = box.addButton("Open it as a new model", QMessageBox.ActionRole)
5075
+ cancel = box.addButton(QMessageBox.Cancel)
5076
+ box.setDefaultButton(update)
5077
+ box.setEscapeButton(cancel)
5078
+ box.exec()
5079
+ clicked = box.clickedButton()
5080
+ return "update" if clicked is update else "new" if clicked is new else "cancel"
5081
+
5082
+ def _model_script_for_config(self, file_path, data):
5083
+ """The newest model script for the settings file's layout that can be edited in place, else None. It holds
5084
+ the settings made in setupEM after the settings file was last saved (Create Model writes the script); the
5085
+ settings file itself is written again by the layout tool on every export, so it is usually the newer
5086
+ file and its age says nothing."""
5087
+ saved = data.get("saved_values") or {}
5088
+ gds = saved.get("GdsFile")
5089
+ if not gds:
5090
+ return None
5091
+ base = os.path.dirname(os.path.abspath(file_path))
5092
+ gds = os.path.join(base, gds)
5093
+ folders = [base, os.path.dirname(os.path.abspath(gds))]
5094
+ if saved.get("sim_path"):
5095
+ folders.append(os.path.join(base, saved["sim_path"]))
5096
+ for script in model_scripts(gds, folders):
5097
+ if not self._in_place_problem(script):
5098
+ return script
5099
+ return None
5100
+
5101
+ def _ask_continue_with_script(self, file_path, script):
5102
+ """A model script exists for the settings file's layout: "script", "config" or "cancel"."""
5103
+ box = QMessageBox(self)
5104
+ box.setWindowTitle("Load Config")
5105
+ box.setIcon(QMessageBox.Question)
5106
+ stamp = time.localtime(os.path.getmtime(script))
5107
+ when = time.strftime("%H:%M" if stamp[:3] == time.localtime()[:3] else "%Y-%m-%d %H:%M", stamp)
5108
+ box.setText(f"{os.path.basename(script)} (changed {when}) is the model script for this layout. It has "
5109
+ "the settings you last used for it (frequencies, mesh, ...), also those not saved in "
5110
+ f"{os.path.basename(file_path)}.\n\n"
5111
+ "Continue with the script: edit it in place, its settings and your own code stay, ports "
5112
+ "and layout settings come from the config.\n\n"
5113
+ "Start from the config: the config's settings for a new model.")
5114
+ script_button = box.addButton("Continue with the script", QMessageBox.AcceptRole)
5115
+ config_button = box.addButton("Start from the config", QMessageBox.ActionRole)
5116
+ cancel = box.addButton(QMessageBox.Cancel)
5117
+ box.setDefaultButton(script_button)
5118
+ box.setEscapeButton(cancel)
5119
+ box.exec()
5120
+ clicked = box.clickedButton()
5121
+ return "script" if clicked is script_button else "config" if clicked is config_button else "cancel"
5122
+
5123
+ def refresh_config_notice(self):
5124
+ """Edit-in-place mode: point out a settings file of the same layout that is newer than the script and has
5125
+ other ports or layout settings, with an Update button (info bar; only when it shows nothing else)."""
5126
+ if self.script_model is None or not self.config_update_supported() or not self.info_bar.isHidden():
5127
+ return
5128
+ gds = self.saved_values.get("GdsFile")
5129
+ script = self.script_model.path
5130
+ if not gds or not script:
5131
+ return
5132
+ folders = [os.path.dirname(os.path.abspath(script)), os.path.dirname(os.path.abspath(gds))]
5133
+ for path, data in newer_configs(script, gds, folders, "." + self.CONFIG_SUFFIX, self.APP_NAME):
5134
+ differences = self.config_update_differences(data, os.path.dirname(os.path.abspath(path)))
5135
+ if differences:
5136
+ stamp = time.localtime(os.path.getmtime(path))
5137
+ when = time.strftime("%H:%M" if stamp[:3] == time.localtime()[:3] else "%Y-%m-%d %H:%M", stamp)
5138
+ self.show_info_bar(f"{os.path.basename(path)} (saved {when}) has other ports or layout settings "
5139
+ f"than this script ({len(differences)} difference(s)).", "Update from it",
5140
+ lambda p=path, d=data: self._update_script_from_config(p, d))
5141
+ return
5142
+
4983
5143
  def _in_place_problem(self, file_path):
4984
5144
  """Why a script can't be opened for editing in place, or "" if it can."""
4985
5145
  try:
@@ -5008,7 +5168,8 @@ class MainWindowBase(QMainWindow):
5008
5168
 
5009
5169
  def import_py_model(self):
5010
5170
  """File > Import from *.py model: its settings for a new, generated model."""
5011
- file_path, _ = QFileDialog.getOpenFileName(self, "Import from *.py Model", filter="*.py model code")
5171
+ file_path, _ = QFileDialog.getOpenFileName(self, "Import from *.py Model", self._open_dialog_folder(),
5172
+ filter="*.py model code")
5012
5173
  if file_path:
5013
5174
  self.load_configuration_from_file(file_path)
5014
5175
 
@@ -5016,7 +5177,8 @@ class MainWindowBase(QMainWindow):
5016
5177
  """File > Edit *.py model in place: Create Model / Save write only the
5017
5178
  changed values back into the script, its own code stays."""
5018
5179
  if not file_path:
5019
- file_path, _ = QFileDialog.getOpenFileName(self, "Edit *.py Model in Place", filter="*.py model code")
5180
+ file_path, _ = QFileDialog.getOpenFileName(self, "Edit *.py Model in Place", self._open_dialog_folder(),
5181
+ filter="*.py model code")
5020
5182
  if not file_path:
5021
5183
  return
5022
5184
  problem = self._in_place_problem(file_path)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: setupEM
3
- Version: 0.15.0
3
+ Version: 0.16.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
@@ -10,7 +10,7 @@ Project-URL: Changelog, https://github.com/VolkerMuehlhaus/setupEM/blob/main/doc
10
10
  Requires-Python: >=3.9
11
11
  Description-Content-Type: text/markdown
12
12
  License-File: LICENSE
13
- Requires-Dist: gds2palace>=0.9.0
13
+ Requires-Dist: gds2palace>=0.9.3
14
14
  Requires-Dist: gds_prepare_for_EM>=1.6.0
15
15
  Requires-Dist: PySide6
16
16
  Requires-Dist: shiboken6
@@ -40,6 +40,24 @@ https://github.com/VolkerMuehlhaus/setupEM
40
40
 
41
41
  ## Recent changes
42
42
 
43
+ # What's New - October 31, 2026 (setupEM 0.16.0)
44
+
45
+ **After a new export from the layout tool** (setupEM): a layout tool such as the KLayout plug-in klayout_em_export writes the GDSII file and a `.simcfg`, and writes both again when the layout changes. The settings you made in setupEM since are only in your model script, because Create Model writes the script, not the `.simcfg`. setupEM now takes from such a `.simcfg` only the ports, GDSII file, stackup, cell and purpose, and keeps everything else as in the script:
46
+ - Opening a `.simcfg` when a model script for the same GDSII file exists next to it asks whether to **Continue with the script** (edit it in place, ports and layout settings from the `.simcfg`) or **Start from the config**.
47
+ - Loading a `.simcfg` while editing a script in place lists the ports and layout settings that differ and asks whether to **Update the script from it** or **Open it as a new model**. Before, loading it ended editing in place and replaced all settings.
48
+ - While editing a script in place, the bar below the menu points out a newer `.simcfg` of the same GDSII file with other ports or layout settings, with **Update from it**.
49
+
50
+ **Load Config, Import from \*.py model and Edit \*.py model in place** start in the folder of the model you are working on (the config or script loaded last, else the GDSII file). Before, they started in the folder setupEM was launched from, e.g. KLayout's when the KLayout plug-in opened setupEM.
51
+
52
+ setupEM 0.16.0 needs gds2palace 0.9.3 or later: a layout with several shapes on one port layer (e.g. a transistor terminal on several fingers, from the KLayout plug-in) makes one port with several elements there; older gds2palace versions used only the last shape.
53
+
54
+ **Fixes:**
55
+ - `scripts/combine_extend_snp.py` is the same as gds2palace's again. It had missed three fixes made there in September: the port impedance lookup for AMR iterations, the missing `.sNp` extension for a model name that contains a dot, and Touchstone output after an interrupted Palace run.
56
+ - On Linux with GNOME (Wayland), the mouse cursor no longer keeps spinning for about 10 seconds after setupEM starts. setupEM briefly opened three invisible windows while building the Input Files tab, and GNOME waited for them to finish starting.
57
+ - Via ports with direction `-Z` keep their from and to layers. Before, loading a `.simcfg` dropped them, and Create Model wrote such a port as an in-plane port.
58
+ - An incomplete port (direction and layers that don't fit, e.g. from a `.simcfg` written by another tool) gives a warning in the Ports tab. Before, gds2palace exited and setupEM closed. A lowercase `z` counts as a via port too.
59
+ - `combine_extend_snp` (Touchstone output after a Palace run) handles ports with several elements (several shapes on one port layer, gds2palace 0.9.3): one Z0 per port in the option line, and the elements' port inductances combined in parallel for de-embedding. Before, the last element overwrote the others.
60
+
43
61
  # What's New - October 29, 2026
44
62
 
45
63
  **File > Edit \*.py model in place** (new, setupEM and setupThermal): edits a gds2palace model script directly, e.g. one from the gds2palace MCP server, an example or your own code. Create Model and **File > Save** write only the values you change; the script's loops, comments and custom code stay. Settings the script computes itself are greyed out. A script that builds several models (a parameter sweep) lists them on the Create Model tab, and Start Simulation runs the ticked ones. After importing a script that setupEM didn't write, a bar below the menu offers **Edit in place instead**.
@@ -56,17 +74,13 @@ Import and edit in place now read a stackup given as `utilities.get_stackup_path
56
74
 
57
75
  Fix: with **Merge via arrays** (and no fill removal or merging per layer), the simplified file still contained the original subcells as extra top-level cells. Without a cell name, gds2palace reads the first top-level cell, which could be one of them, e.g. a pad cell instead of the design. These left-over cells are now removed.
58
76
 
59
- # What's New - October 19, 2026
60
-
61
- **Stackup Editor, ADS Momentum (*.ltd) import:** metals with an EXPAND thickness operation now make the dielectric next to them thicker, as in ADS, instead of reaching into the dielectric below. Before, a thick EXPAND DOWN metal (e.g. MET1/MET2 in a GaAs stackup) overlapped the layers underneath it, and a via could end up with zero height. Files where long lines are wrapped or END_... markers follow the last entry on the same line now import too; before, they failed with "No usable dielectric slabs".
62
-
63
77
  Full history: [CHANGES.md](https://github.com/VolkerMuehlhaus/setupEM/blob/main/doc/CHANGES.md)
64
78
 
65
79
  ---
66
80
 
67
81
  **Note:** the `setupEM` PyPI package itself requires:
68
82
 
69
- - gds2palace>=0.9.0
83
+ - gds2palace>=0.9.3
70
84
  - gds_prepare_for_EM>=1.6.0
71
85
  - PySide6
72
86
  - shiboken6
@@ -4,6 +4,7 @@ README_pypi.md
4
4
  pyproject.toml
5
5
  src/setupEM/__init__.py
6
6
  src/setupEM/__main__.py
7
+ src/setupEM/config_update.py
7
8
  src/setupEM/field_viewer.py
8
9
  src/setupEM/gds_hierarchy_scan.py
9
10
  src/setupEM/layout_preview.py
@@ -32,6 +33,8 @@ src/setupEM/data/SG13G2_200um.xml
32
33
  src/setupEM/data/SG13G2_FEM_200um.xml
33
34
  src/setupEM/data/SG13G2_FEM_200um_passi3D.xml
34
35
  src/setupEM/data/SG13G2_nosub.xml
36
+ tests/test_config_update.py
37
+ tests/test_config_update_gui.py
35
38
  tests/test_run_with_overrides.py
36
39
  tests/test_script_model.py
37
40
  tests/test_script_probe.py
@@ -1,4 +1,4 @@
1
- gds2palace>=0.9.0
1
+ gds2palace>=0.9.3
2
2
  gds_prepare_for_EM>=1.6.0
3
3
  PySide6
4
4
  shiboken6
@@ -0,0 +1,83 @@
1
+ # Tests for updating a script edited in place from a *.simcfg of the same layout (src/setupEM/config_update.py).
2
+ #
3
+ # Run from the repo root: python -m pytest tests
4
+
5
+ import json
6
+ import os
7
+ import sys
8
+ import time
9
+
10
+ HERE = os.path.dirname(os.path.abspath(__file__))
11
+ sys.path.insert(0, os.path.join(HERE, "..", "src"))
12
+
13
+ from setupEM.config_update import (layout_differences, model_scripts, newer_configs, # noqa: E402
14
+ port_differences)
15
+
16
+
17
+ def via(n, layer, frm="Metal1", to="TopMetal2", direction="Z", z0=50.0, voltage=1.0):
18
+ return {"portnumber": n, "source_layernum": layer, "from_layername": frm, "to_layername": to,
19
+ "target_layername": None, "direction": direction, "port_Z0": z0, "voltage": voltage}
20
+
21
+
22
+ def test_same_ports_no_differences():
23
+ ports = [via(1, 201), via(2, 202)]
24
+ assert port_differences(ports, [dict(p) for p in ports]) == []
25
+
26
+
27
+ def test_z0_and_excitation_are_not_compared():
28
+ assert port_differences([via(1, 201)], [via(1, 201, z0=25.0, voltage=0.0)]) == []
29
+
30
+
31
+ def test_renumbered_new_and_removed_ports():
32
+ old = [via(1, 201), via(2, 202), via(3, 203)]
33
+ new = [via(1, 201), via(2, 203), via(3, 204, "Metal2", "Metal5", "-Z"), via(4, 205)]
34
+ diff = port_differences(old, new)
35
+ assert diff[0] == "port 2: was via Metal1 -> TopMetal2 (Z) on layer 202, now via Metal1 -> TopMetal2 (Z) on layer 203"
36
+ assert diff[1].startswith("port 3: was") and "(-Z)" in diff[1]
37
+ assert diff[2].startswith("port 4: new")
38
+ assert port_differences(new, old)[-1] == "port 4: removed"
39
+
40
+
41
+ def test_direction_case_and_in_plane_ports():
42
+ in_plane = {"portnumber": 1, "source_layernum": "201", "direction": "x", "target_layername": "Metal5",
43
+ "from_layername": "", "to_layername": ""}
44
+ assert port_differences([in_plane], [dict(in_plane, direction="X", source_layernum=201)]) == []
45
+
46
+
47
+ def test_layout_differences(tmp_path):
48
+ gds = tmp_path / "chip_em.gds"
49
+ old = {"GdsFile": str(gds), "SubstrateFile": "/x/SG13G2.xml", "cellname": "TOP", "purpose": [0]}
50
+ assert layout_differences(old, {"GdsFile": "chip_em.gds", "purpose": ["0"]}, str(tmp_path)) == []
51
+ diff = layout_differences(old, {"GdsFile": "other_em.gds", "cellname": "CORE"}, str(tmp_path))
52
+ assert diff == ["GDSII file: chip_em.gds -> other_em.gds", "cell: TOP -> CORE"]
53
+
54
+
55
+ def _touch(path, text, age):
56
+ path.write_text(text)
57
+ stamp = time.time() - age
58
+ os.utime(path, (stamp, stamp))
59
+
60
+
61
+ def test_newer_configs_and_scripts(tmp_path):
62
+ gds = tmp_path / "chip_em.gds"
63
+ gds.write_bytes(b"")
64
+ config = {"application": "setupEM", "saved_values": {"GdsFile": "chip_em.gds"}, "ports": [via(1, 201)]}
65
+ script_text = f"settings = {{}}\nsettings['GdsFile'] = {str(gds)!r}\n"
66
+ _touch(tmp_path / "chip_em.py", script_text, 100)
67
+ _touch(tmp_path / "chip_em.simcfg", json.dumps(config), 10)
68
+ _touch(tmp_path / "other.simcfg", json.dumps(dict(config, saved_values={"GdsFile": "other.gds"})), 5)
69
+ _touch(tmp_path / "thermal.simcfg", json.dumps(dict(config, application="setupThermal")), 5)
70
+
71
+ found = newer_configs(str(tmp_path / "chip_em.py"), str(gds), [str(tmp_path), str(tmp_path)])
72
+ assert [os.path.basename(p) for p, _ in found] == ["chip_em.simcfg"]
73
+
74
+ # model scripts for the GDS, whatever their age against the config (the layout tool writes the config again on
75
+ # every export), newest first
76
+ _touch(tmp_path / "relative.py", "settings = {}\nsettings['GdsFile'] = 'chip_em.gds'\n", 2)
77
+ _touch(tmp_path / "unrelated.py", "x = 'chip_em_old.gds'\n", 1)
78
+ scripts = model_scripts(str(gds), [str(tmp_path), str(tmp_path)])
79
+ assert [os.path.basename(p) for p in scripts] == ["relative.py", "chip_em.py"]
80
+
81
+ # the script saved after the config: the config is no longer newer
82
+ _touch(tmp_path / "chip_em.py", script_text, 1)
83
+ assert newer_configs(str(tmp_path / "chip_em.py"), str(gds), [str(tmp_path)]) == []
@@ -0,0 +1,169 @@
1
+ # GUI test (offscreen) for updating a script edited in place from a *.simcfg of the same layout:
2
+ # - opening a .simcfg when a newer model script for the same GDS exists ("Continue with the script"),
3
+ # - loading a .simcfg while a script is edited in place ("Update the script from it" / "Open it as a new model"),
4
+ # - the info bar notice about a newer .simcfg, with its "Update from it" button.
5
+ # Needs the sibling repo gds2palace_ihp_sg13g2 (GDS and stackup); skipped without it.
6
+ #
7
+ # Run from the repo root: python -m pytest tests
8
+
9
+ import json
10
+ import os
11
+ import shutil
12
+ import sys
13
+ import time
14
+
15
+ import pytest
16
+
17
+ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
18
+ HERE = os.path.dirname(os.path.abspath(__file__))
19
+ sys.path.insert(0, os.path.join(HERE, "..", "src"))
20
+ G2P = os.path.join(HERE, "..", "..", "gds2palace_ihp_sg13g2")
21
+ GDS = os.path.join(G2P, "workflow", "inductor_500pH_with_ports.gds")
22
+ XML = os.path.join(G2P, "XML_stackup", "legacy", "SG13G2_200um.xml")
23
+ pytestmark = pytest.mark.skipif(not (os.path.isfile(GDS) and os.path.isfile(XML)),
24
+ reason="needs gds2palace_ihp_sg13g2 next to this repo")
25
+
26
+ from PySide6.QtWidgets import QApplication, QMessageBox # noqa: E402
27
+
28
+
29
+ def via(n, layer, direction="Z", frm="Metal1", to="TopMetal2"):
30
+ return {"portnumber": n, "source_layernum": layer, "target_layername": None, "from_layername": frm,
31
+ "to_layername": to, "direction": direction, "port_Z0": 50.0, "voltage": 1.0}
32
+
33
+
34
+ def set_age(path, seconds):
35
+ stamp = time.time() - seconds
36
+ os.utime(path, (stamp, stamp))
37
+
38
+
39
+ @pytest.fixture
40
+ def win(monkeypatch):
41
+ app = QApplication.instance() or QApplication([])
42
+ for name, answer in (("information", QMessageBox.Ok), ("warning", QMessageBox.Ok),
43
+ ("question", QMessageBox.Yes)):
44
+ monkeypatch.setattr(QMessageBox, name, staticmethod(lambda *a, answer=answer, **k: answer))
45
+ from setupEM.setupEM import MainWindow
46
+ window = MainWindow()
47
+ yield window
48
+ window._script_dirty = False
49
+ window.close()
50
+ app.processEvents()
51
+
52
+
53
+ @pytest.fixture
54
+ def layout(tmp_path):
55
+ """A setupEM script with its own comment, and a .simcfg exported later with other ports and frequencies."""
56
+ gds = tmp_path / "chip_em.gds"
57
+ shutil.copy(GDS, gds)
58
+ script = (open(os.path.join(HERE, "fixtures", "generated_setupEM_palace.py"), encoding="utf-8").read()
59
+ .replace("settings['GdsFile'] = 'inductor_500pH_with_ports.gds'",
60
+ f"settings['GdsFile'] = {str(gds).replace(os.sep, '/')!r}")
61
+ .replace("settings['SubstrateFile'] = '../XML_stackup/legacy/SG13G2_200um.xml'",
62
+ f"settings['SubstrateFile'] = {os.path.abspath(XML).replace(os.sep, '/')!r}")
63
+ .replace("settings['fstop'] = 100.0e9", "settings['fstop'] = 100.0e9 # my own note"))
64
+ assert "# my own note" in script and str(gds).replace(os.sep, "/") in script
65
+ (tmp_path / "chip_em.py").write_text(script, encoding="utf-8")
66
+
67
+ def write_config(name, ports, age):
68
+ data = {"application": "setupEM", "data_format": "1.0",
69
+ "saved_values": {"unit": 1e-06, "GdsFile": "chip_em.gds", "SubstrateFile": os.path.abspath(XML),
70
+ "cellname": "", "purpose": [0], "fstart": 0.0, "fstop": 7.0, "fstep": 1.0},
71
+ "ports": ports}
72
+ path = tmp_path / name
73
+ path.write_text(json.dumps(data), encoding="utf-8")
74
+ set_age(path, age)
75
+ return str(path)
76
+
77
+ return tmp_path, write_config
78
+
79
+
80
+ def code_of(win):
81
+ win.save_all_tabs()
82
+ return win.preserve_mode_patch(win.script_model.path)[0]
83
+
84
+
85
+ def port_lines(code):
86
+ return [line for line in code.splitlines() if "simulation_port(" in line and not line.lstrip().startswith("#")]
87
+
88
+
89
+ def test_open_config_continues_with_script(win, layout, monkeypatch):
90
+ folder, write_config = layout
91
+ # the usual order: Create Model wrote the script, then the layout tool exported again (a newer config)
92
+ set_age(folder / "chip_em.py", 100)
93
+ config = write_config("chip_em.simcfg", [via(1, 201), via(2, 202, "-Z"), via(3, 203)], age=10)
94
+ asked = []
95
+ monkeypatch.setattr(win, "_ask_continue_with_script",
96
+ lambda c, s: asked.append(os.path.basename(s)) or "script")
97
+ win.load_configuration_from_file(config)
98
+ assert asked == ["chip_em.py"]
99
+ assert win.script_model is not None and os.path.basename(win.script_model.path) == "chip_em.py"
100
+ code = code_of(win)
101
+ assert "settings['fstop'] = 100.0e9 # my own note" in code # the script's settings and comment stay
102
+ lines = port_lines(code)
103
+ assert len(lines) == 3
104
+ assert "source_layernum=202" in lines[1] and "'-Z'" in lines[1]
105
+ assert "source_layernum=203" in lines[2]
106
+
107
+
108
+ def test_open_config_without_script_asks_nothing(win, layout, monkeypatch):
109
+ folder, write_config = layout
110
+ os.remove(folder / "chip_em.py") # no model script for this layout yet
111
+ config = write_config("chip_em.simcfg", [via(1, 201)], age=10)
112
+ monkeypatch.setattr(win, "_ask_continue_with_script", lambda c, s: pytest.fail("asked"))
113
+ win.load_configuration_from_file(config)
114
+ assert win.script_model is None and win.user_inputs_file == config
115
+ # Load Config / Import / Edit in place start in the folder of the config, not the working folder
116
+ assert os.path.normcase(win._open_dialog_folder()) == os.path.normcase(str(folder))
117
+
118
+
119
+ def test_start_from_config(win, layout, monkeypatch):
120
+ folder, write_config = layout
121
+ config = write_config("chip_em.simcfg", [via(1, 201)], age=100)
122
+ set_age(folder / "chip_em.py", 10)
123
+ monkeypatch.setattr(win, "_ask_continue_with_script", lambda c, s: "config")
124
+ win.load_configuration_from_file(config)
125
+ assert win.script_model is None and win.user_inputs_file == config
126
+
127
+
128
+ def test_load_config_while_editing_in_place(win, layout, monkeypatch):
129
+ folder, write_config = layout
130
+ script = str(folder / "chip_em.py")
131
+ win.edit_py_model(script)
132
+ assert win.script_model is not None
133
+ config = write_config("chip_em.simcfg", [via(1, 201), via(2, 202), via(3, 204)], age=0)
134
+ seen = []
135
+
136
+ def ask(path, data):
137
+ win.save_all_tabs()
138
+ seen.append(win.config_update_differences(data, str(folder)))
139
+ return "update"
140
+ monkeypatch.setattr(win, "_ask_update_script_from_config", ask)
141
+ win.load_configuration_from_file(config)
142
+ assert seen and seen[0] == ["port 3: new, via Metal1 -> TopMetal2 (Z) on layer 204"]
143
+ assert win.script_model is not None
144
+ lines = port_lines(code_of(win))
145
+ assert len(lines) == 3 and "source_layernum=204" in lines[2]
146
+
147
+ monkeypatch.setattr(win, "_ask_update_script_from_config", lambda p, d: "new")
148
+ win._script_dirty = False
149
+ monkeypatch.setattr(win, "confirm_unsaved_script", lambda: True)
150
+ win.load_configuration_from_file(config)
151
+ assert win.script_model is None and win.user_inputs_file == config
152
+
153
+
154
+ def test_notice_about_newer_config(win, layout):
155
+ folder, write_config = layout
156
+ script = folder / "chip_em.py"
157
+ set_age(script, 100)
158
+ win.edit_py_model(str(script))
159
+ win.hide_info_bar()
160
+ write_config("chip_em.simcfg", [via(1, 201), via(2, 202, "-Z")], age=10)
161
+ win.refresh_config_notice()
162
+ assert not win.info_bar.isHidden() and win.info_bar_button.text() == "Update from it"
163
+ assert "chip_em.simcfg" in win.info_bar_label.text()
164
+ win._info_bar_action_clicked()
165
+ lines = port_lines(code_of(win))
166
+ assert "'-Z'" in lines[1]
167
+ win.hide_info_bar()
168
+ win.refresh_config_notice() # the settings now match the config: no notice
169
+ assert win.info_bar.isHidden()
File without changes
File without changes
File without changes