setupEM 0.6.0__tar.gz → 0.6.1__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.
- {setupem-0.6.0/src/setupEM.egg-info → setupem-0.6.1}/PKG-INFO +2 -1
- {setupem-0.6.0 → setupem-0.6.1}/pyproject.toml +2 -1
- {setupem-0.6.0 → setupem-0.6.1}/src/setupEM/__init__.py +1 -1
- {setupem-0.6.0 → setupem-0.6.1}/src/setupEM/palace_results.py +18 -0
- {setupem-0.6.0 → setupem-0.6.1}/src/setupEM/setupEM.py +104 -27
- {setupem-0.6.0 → setupem-0.6.1}/src/setupEM/setupThermal.py +54 -0
- {setupem-0.6.0 → setupem-0.6.1}/src/setupEM/setup_common.py +74 -5
- setupem-0.6.1/src/setupEM/thermal_results.py +175 -0
- {setupem-0.6.0 → setupem-0.6.1/src/setupEM.egg-info}/PKG-INFO +2 -1
- {setupem-0.6.0 → setupem-0.6.1}/src/setupEM.egg-info/SOURCES.txt +1 -0
- {setupem-0.6.0 → setupem-0.6.1}/src/setupEM.egg-info/requires.txt +1 -0
- {setupem-0.6.0 → setupem-0.6.1}/LICENSE +0 -0
- {setupem-0.6.0 → setupem-0.6.1}/README.md +0 -0
- {setupem-0.6.0 → setupem-0.6.1}/README_pypi.md +0 -0
- {setupem-0.6.0 → setupem-0.6.1}/setup.cfg +0 -0
- {setupem-0.6.0 → setupem-0.6.1}/src/setupEM/__main__.py +0 -0
- {setupem-0.6.0 → setupem-0.6.1}/src/setupEM/data/SG13G2_100um.xml +0 -0
- {setupem-0.6.0 → setupem-0.6.1}/src/setupEM/data/SG13G2_200um.xml +0 -0
- {setupem-0.6.0 → setupem-0.6.1}/src/setupEM/data/SG13G2_200um_with_parameters.xml +0 -0
- {setupem-0.6.0 → setupem-0.6.1}/src/setupEM/data/SG13G2_nosub.xml +0 -0
- {setupem-0.6.0 → setupem-0.6.1}/src/setupEM/momentum_import.py +0 -0
- {setupem-0.6.0 → setupem-0.6.1}/src/setupEM/result_viewer.py +0 -0
- {setupem-0.6.0 → setupem-0.6.1}/src/setupEM/stackupEditor.py +0 -0
- {setupem-0.6.0 → setupem-0.6.1}/src/setupEM/stackup_writer.py +0 -0
- {setupem-0.6.0 → setupem-0.6.1}/src/setupEM.egg-info/dependency_links.txt +0 -0
- {setupem-0.6.0 → setupem-0.6.1}/src/setupEM.egg-info/entry_points.txt +0 -0
- {setupem-0.6.0 → setupem-0.6.1}/src/setupEM.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: setupEM
|
|
3
|
-
Version: 0.6.
|
|
3
|
+
Version: 0.6.1
|
|
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
|
|
@@ -15,6 +15,7 @@ Requires-Dist: scikit-rf
|
|
|
15
15
|
Requires-Dist: matplotlib
|
|
16
16
|
Requires-Dist: numpy
|
|
17
17
|
Requires-Dist: gdspy
|
|
18
|
+
Requires-Dist: meshio
|
|
18
19
|
Dynamic: license-file
|
|
19
20
|
|
|
20
21
|
# Python GUI for gds2palace
|
|
@@ -27,6 +27,7 @@ import csv
|
|
|
27
27
|
import re
|
|
28
28
|
import math
|
|
29
29
|
import cmath
|
|
30
|
+
import glob
|
|
30
31
|
|
|
31
32
|
_ITERATION_RE = re.compile(r'^iteration(\d+)$')
|
|
32
33
|
|
|
@@ -47,6 +48,23 @@ def find_output_dir(run_path, model_basename):
|
|
|
47
48
|
return os.path.normpath(os.path.join(run_path, output_rel))
|
|
48
49
|
|
|
49
50
|
|
|
51
|
+
def find_paraview_files(run_path, model_basename):
|
|
52
|
+
"""All ParaView collection files under this run's Palace output directory, found
|
|
53
|
+
recursively. Palace's own field-dump feature (triggered by fdump/SaveStep) writes
|
|
54
|
+
its category/excitation folder structure without gds2palace's control - confirmed
|
|
55
|
+
(on two Palace versions) to only keep the last-solved excitation's Cycle files, so
|
|
56
|
+
no fixed filename or count can be assumed here. Falls back to loose .vtu if no .pvd
|
|
57
|
+
exists yet. Empty list if the output directory doesn't exist / has nothing yet.
|
|
58
|
+
"""
|
|
59
|
+
output_dir = find_output_dir(run_path, model_basename)
|
|
60
|
+
if not os.path.isdir(output_dir):
|
|
61
|
+
return []
|
|
62
|
+
pvd_files = sorted(glob.glob(os.path.join(output_dir, '**', '*.pvd'), recursive=True))
|
|
63
|
+
if pvd_files:
|
|
64
|
+
return pvd_files
|
|
65
|
+
return sorted(glob.glob(os.path.join(output_dir, '**', '*.vtu'), recursive=True))
|
|
66
|
+
|
|
67
|
+
|
|
50
68
|
def _read_palace_json(dir_path):
|
|
51
69
|
path = os.path.join(dir_path, 'palace.json')
|
|
52
70
|
if not os.path.isfile(path):
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
########################################################################
|
|
18
18
|
|
|
19
19
|
|
|
20
|
-
import sys, json, os, pathlib, ast, webbrowser, argparse, shutil, re
|
|
20
|
+
import sys, json, os, pathlib, ast, webbrowser, argparse, shutil, re, glob
|
|
21
21
|
import numpy as np
|
|
22
22
|
import importlib.metadata
|
|
23
23
|
import importlib.util
|
|
@@ -55,7 +55,7 @@ if __package__ in (None, ""):
|
|
|
55
55
|
VectorWidget, PopUpWindow, CreateModelTabBase, MainWindowBase,
|
|
56
56
|
epsilon_to_color, default_stackup_dielectric_label, default_stackup_metal_label,
|
|
57
57
|
)
|
|
58
|
-
from palace_results import build_results_summary, find_output_dir
|
|
58
|
+
from palace_results import build_results_summary, find_output_dir, find_paraview_files
|
|
59
59
|
else:
|
|
60
60
|
from .setup_common import (
|
|
61
61
|
EDIT_STYLE_OPTIONAL, EDIT_STYLE_REQUIRED, COMBO_STYLE_REQUIRED, COMBO_STYLE_OPTIONAL,
|
|
@@ -63,7 +63,7 @@ else:
|
|
|
63
63
|
VectorWidget, PopUpWindow, CreateModelTabBase, MainWindowBase,
|
|
64
64
|
epsilon_to_color, default_stackup_dielectric_label, default_stackup_metal_label,
|
|
65
65
|
)
|
|
66
|
-
from .palace_results import build_results_summary, find_output_dir
|
|
66
|
+
from .palace_results import build_results_summary, find_output_dir, find_paraview_files
|
|
67
67
|
|
|
68
68
|
|
|
69
69
|
'''
|
|
@@ -244,6 +244,8 @@ class FrequenciesTab(QWidget):
|
|
|
244
244
|
saved_values ["fdump"] = ast.literal_eval('['+text+']')
|
|
245
245
|
else:
|
|
246
246
|
saved_values.pop("fdump",None)
|
|
247
|
+
# "View fields in Paraview" is only relevant once fdump is set
|
|
248
|
+
self.MainWindow.create_model_tab._update_paraview_button_visibility()
|
|
247
249
|
|
|
248
250
|
# if fstart == fstop == fdump or fstart == fstop == fstep, then remove fstart, fstop
|
|
249
251
|
if fstart is not None and fstop is not None and fstart == fstop:
|
|
@@ -1285,13 +1287,26 @@ class CreateModelTab(CreateModelTabBase):
|
|
|
1285
1287
|
# up exactly with Preview/Create Mesh/Start Simulation above, in the same
|
|
1286
1288
|
# Actions group.
|
|
1287
1289
|
row = self.buttons_grid.rowCount()
|
|
1288
|
-
self.view_results_btn = QPushButton("📈 View
|
|
1290
|
+
self.view_results_btn = QPushButton("📈 View S-Parameters...")
|
|
1289
1291
|
self.view_results_btn.clicked.connect(self.MainWindow.open_result_viewer)
|
|
1290
1292
|
self.buttons_grid.addWidget(self.view_results_btn, row, 0)
|
|
1291
1293
|
self.model_fit_btn = QPushButton("🧩 Model Fit...")
|
|
1292
1294
|
self.model_fit_btn.clicked.connect(self.open_model_fit)
|
|
1293
1295
|
self.buttons_grid.addWidget(self.model_fit_btn, row, 1)
|
|
1294
1296
|
|
|
1297
|
+
# "View fields in Paraview" opens field-dump data (Palace fdump / Elmer EM
|
|
1298
|
+
# fields*.vtu), a separate row since it's independent of the S-parameter
|
|
1299
|
+
# viewer/model fit above. Only meaningful when fdump is set (otherwise there's
|
|
1300
|
+
# never any field data to open), so it's hidden rather than shown greyed-out -
|
|
1301
|
+
# kept in sync with saved_values['fdump'] via _update_paraview_button_visibility(),
|
|
1302
|
+
# called from here, from load_values() (project/model import), and from
|
|
1303
|
+
# FrequenciesTab.save_values() (live edits to the fdump field).
|
|
1304
|
+
row = self.buttons_grid.rowCount()
|
|
1305
|
+
self.paraview_btn = QPushButton("🖼️ View fields in Paraview...")
|
|
1306
|
+
self.paraview_btn.clicked.connect(self.launch_paraview)
|
|
1307
|
+
self.buttons_grid.addWidget(self.paraview_btn, row, 0)
|
|
1308
|
+
self._update_paraview_button_visibility()
|
|
1309
|
+
|
|
1295
1310
|
def open_model_fit(self):
|
|
1296
1311
|
SNP2LE_URL = "https://github.com/iic-jku/snp2le"
|
|
1297
1312
|
|
|
@@ -1370,6 +1385,43 @@ class CreateModelTab(CreateModelTabBase):
|
|
|
1370
1385
|
summary = build_results_summary(run_path, saved_values['model_basename'])
|
|
1371
1386
|
self.log_area.appendPlainText("\n" + summary + "\n")
|
|
1372
1387
|
|
|
1388
|
+
def _update_paraview_button_visibility(self):
|
|
1389
|
+
self.paraview_btn.setVisible(bool(saved_values.get('fdump')))
|
|
1390
|
+
|
|
1391
|
+
def load_values(self):
|
|
1392
|
+
super().load_values()
|
|
1393
|
+
self._update_paraview_button_visibility()
|
|
1394
|
+
|
|
1395
|
+
def launch_paraview(self):
|
|
1396
|
+
if self.MainWindow.PalaceMode:
|
|
1397
|
+
run_path = saved_values['sim_path'] + "/palace_model/" + saved_values['model_basename'] + "_data"
|
|
1398
|
+
file_paths = find_paraview_files(run_path, saved_values['model_basename'])
|
|
1399
|
+
not_found = (
|
|
1400
|
+
f"⚠️ No Palace field-dump output found under "
|
|
1401
|
+
f"{find_output_dir(run_path, saved_values['model_basename'])}\n"
|
|
1402
|
+
"(set fdump to specific frequencies before running the simulation)\n"
|
|
1403
|
+
)
|
|
1404
|
+
else:
|
|
1405
|
+
run_path = saved_values['sim_path'] + "/elmer_model/" + saved_values['model_basename'] + "_data"
|
|
1406
|
+
# Output File Name = File "fields" has no path prefix, so Elmer resolves it
|
|
1407
|
+
# relative to the Mesh DB directory ("mesh" under run_path) rather than
|
|
1408
|
+
# run_path itself - confirmed against a real run (same resolution mechanism
|
|
1409
|
+
# found for thermal_results.vtu). Check run_path too, defensively.
|
|
1410
|
+
search_dirs = [os.path.join(run_path, "mesh"), run_path]
|
|
1411
|
+
file_paths = []
|
|
1412
|
+
for pattern in ("fields*.pvd", "fields*.vtu"):
|
|
1413
|
+
for d in search_dirs:
|
|
1414
|
+
file_paths = sorted(glob.glob(os.path.join(d, pattern)))
|
|
1415
|
+
if file_paths:
|
|
1416
|
+
break
|
|
1417
|
+
if file_paths:
|
|
1418
|
+
break
|
|
1419
|
+
not_found = (
|
|
1420
|
+
f"⚠️ No Elmer field-dump output found under {run_path}\n"
|
|
1421
|
+
"(set fdump to specific frequencies before running the simulation)\n"
|
|
1422
|
+
)
|
|
1423
|
+
self._open_in_paraview(file_paths, not_found)
|
|
1424
|
+
|
|
1373
1425
|
def on_finished(self, exit_code, exit_status):
|
|
1374
1426
|
super().on_finished(exit_code, exit_status)
|
|
1375
1427
|
# Auto-append the results summary after a real simulation run (not after mesh creation)
|
|
@@ -1431,9 +1483,13 @@ class CreateModelTab(CreateModelTabBase):
|
|
|
1431
1483
|
|
|
1432
1484
|
Palace writes all of its results into one dedicated subdirectory (named in
|
|
1433
1485
|
config.json's Problem.Output, resolved via find_output_dir()), so that whole
|
|
1434
|
-
subtree is the deletion target. Elmer
|
|
1435
|
-
|
|
1436
|
-
|
|
1486
|
+
subtree is the deletion target. Elmer's SaveScalars/ResultOutputSolver write
|
|
1487
|
+
bare filenames (no path prefix) in their .sif config, so Elmer resolves them
|
|
1488
|
+
relative to the Mesh DB directory ("mesh" under run_path) rather than run_path
|
|
1489
|
+
itself - confirmed against real runs (same resolution mechanism found for
|
|
1490
|
+
thermal_results.vtu, and combine_snp.py itself expects "scalar_results" under
|
|
1491
|
+
a "mesh" parent and writes the resulting .sNp file there too). run_path itself
|
|
1492
|
+
is also checked, defensively, in case some variant writes there directly.
|
|
1437
1493
|
"""
|
|
1438
1494
|
if self.MainWindow.PalaceMode:
|
|
1439
1495
|
output_dir = find_output_dir(run_path, saved_values['model_basename'])
|
|
@@ -1442,12 +1498,14 @@ class CreateModelTab(CreateModelTabBase):
|
|
|
1442
1498
|
targets = [output_dir]
|
|
1443
1499
|
else:
|
|
1444
1500
|
targets = []
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1501
|
+
for search_dir in (os.path.join(run_path, "mesh"), run_path):
|
|
1502
|
+
if not os.path.isdir(search_dir):
|
|
1503
|
+
continue
|
|
1504
|
+
for fn in os.listdir(search_dir):
|
|
1505
|
+
full_path = os.path.join(search_dir, fn)
|
|
1448
1506
|
if not os.path.isfile(full_path):
|
|
1449
1507
|
continue
|
|
1450
|
-
if fn in ("scalar_results
|
|
1508
|
+
if fn in ("scalar_results", "scalar_results.names") or \
|
|
1451
1509
|
fn.startswith("fields") or re.search(r'\.s\d+p$', fn, re.IGNORECASE):
|
|
1452
1510
|
targets.append(full_path)
|
|
1453
1511
|
if not targets:
|
|
@@ -1546,17 +1604,27 @@ class CreateModelTab(CreateModelTabBase):
|
|
|
1546
1604
|
|
|
1547
1605
|
self.log_area.appendPlainText('Setting work directory ' + run_path)
|
|
1548
1606
|
|
|
1549
|
-
#
|
|
1550
|
-
#
|
|
1551
|
-
#
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1607
|
+
# create_elmer_run_script() (util_utilities.py) writes run_elmer.bat
|
|
1608
|
+
# directly on Windows (proper .bat content - no "#!/bin/bash" shebang,
|
|
1609
|
+
# and MS-MPI's "mpiexec -n N" instead of the Linux-only "mpirun -np N"),
|
|
1610
|
+
# so no rename step is needed here any more.
|
|
1611
|
+
if saved_values.get('ELMER_MPI_THREADS', 1) > 1 and shutil.which("mpiexec") is None:
|
|
1612
|
+
self.log_area.appendPlainText(
|
|
1613
|
+
"⚠️ This model requests MPI multithreading, but 'mpiexec' was not "
|
|
1614
|
+
"found on PATH. On Windows, Elmer uses Microsoft MPI - download and "
|
|
1615
|
+
"install it from "
|
|
1616
|
+
"https://learn.microsoft.com/en-us/message-passing-interface/microsoft-mpi "
|
|
1617
|
+
"and restart setupEM, or switch to 1 thread (no multithreading) on the "
|
|
1618
|
+
"Mesh and Boundaries tab.\n"
|
|
1619
|
+
)
|
|
1620
|
+
return
|
|
1557
1621
|
self.process.setWorkingDirectory(run_path)
|
|
1558
|
-
# start simulation
|
|
1559
|
-
|
|
1622
|
+
# start simulation - full path, not just "run_elmer.bat": Windows'
|
|
1623
|
+
# CreateProcess resolves a bare relative program name against the
|
|
1624
|
+
# CALLING process's own cwd/PATH, not the child's setWorkingDirectory(),
|
|
1625
|
+
# so a bare filename here silently fails with FailedToStart even though
|
|
1626
|
+
# setWorkingDirectory() is set correctly.
|
|
1627
|
+
self.process.start(os.path.join(run_path, "run_elmer.bat"))
|
|
1560
1628
|
else:
|
|
1561
1629
|
# Linux
|
|
1562
1630
|
self.log_area.appendPlainText('Setting work directory ' + run_path)
|
|
@@ -1895,7 +1963,9 @@ class MainWindow(MainWindowBase):
|
|
|
1895
1963
|
self.PalaceMode = False
|
|
1896
1964
|
self.ElmerMode = True
|
|
1897
1965
|
self.setWindowTitle(APP_NAME + ' Elmer')
|
|
1898
|
-
|
|
1966
|
+
# fdump now also enables Elmer EM field dumps (Exec Solver = Always), not just
|
|
1967
|
+
# Palace's SaveStep - so this group must stay visible/usable in Elmer mode too.
|
|
1968
|
+
self.frequencies_tab.dump_group.setVisible(True)
|
|
1899
1969
|
self.mesh_tab.AMR_group.setVisible(False)
|
|
1900
1970
|
self.mesh_tab.Elmer_group.setVisible(True)
|
|
1901
1971
|
|
|
@@ -1969,13 +2039,20 @@ class MainWindow(MainWindowBase):
|
|
|
1969
2039
|
ports = parse_python_ports_definitions(file_path)
|
|
1970
2040
|
self.ports_tab.update_port_from_import(ports)
|
|
1971
2041
|
|
|
1972
|
-
# Elmer/Palace mode
|
|
1973
|
-
#
|
|
1974
|
-
#
|
|
1975
|
-
#
|
|
2042
|
+
# Elmer/Palace mode isn't captured by the general settings-dict import
|
|
2043
|
+
# (parse_assignments() skips lines whose value contains "settings", and
|
|
2044
|
+
# 'elmer' isn't in import_mapping anyway), so detect it directly from the
|
|
2045
|
+
# source text instead. Two valid ways a script selects Elmer mode:
|
|
2046
|
+
# - calling simulation_setup.create_elmer(...) (what create_model_text()
|
|
2047
|
+
# itself generates, with a space before "(" - a plain "create_elmer("
|
|
2048
|
+
# substring check never matches that)
|
|
2049
|
+
# - setting settings['elmer'] = True directly and calling create_model()
|
|
2050
|
+
# itself (create_elmer() is only a thin wrapper that sets this same flag)
|
|
1976
2051
|
with open(file_path) as f:
|
|
1977
2052
|
text = f.read()
|
|
1978
|
-
|
|
2053
|
+
elmer_call = re.search(r'create_elmer\s*\(', text)
|
|
2054
|
+
elmer_flag = re.search(r'settings\s*\[\s*[\'"]elmer[\'"]\s*\]\s*=\s*True\b', text, re.IGNORECASE)
|
|
2055
|
+
if elmer_call or elmer_flag:
|
|
1979
2056
|
self.setElmerMode()
|
|
1980
2057
|
else:
|
|
1981
2058
|
self.setPalaceMode()
|
|
@@ -53,12 +53,14 @@ if __package__ in (None, ""):
|
|
|
53
53
|
FileDropLineEdit, FileInputTab, PythonHighlighter, CodeEditor,
|
|
54
54
|
VectorWidget, PopUpWindow, CreateModelTabBase, MainWindowBase,
|
|
55
55
|
)
|
|
56
|
+
from thermal_results import build_thermal_summary, format_source_table, find_thermal_vtu
|
|
56
57
|
else:
|
|
57
58
|
from .setup_common import (
|
|
58
59
|
EDIT_STYLE_OPTIONAL, EDIT_STYLE_REQUIRED, COMBO_STYLE_REQUIRED, COMBO_STYLE_OPTIONAL,
|
|
59
60
|
FileDropLineEdit, FileInputTab, PythonHighlighter, CodeEditor,
|
|
60
61
|
VectorWidget, PopUpWindow, CreateModelTabBase, MainWindowBase,
|
|
61
62
|
)
|
|
63
|
+
from .thermal_results import build_thermal_summary, format_source_table, find_thermal_vtu
|
|
62
64
|
|
|
63
65
|
|
|
64
66
|
'''
|
|
@@ -569,6 +571,56 @@ class CreateModelTab(CreateModelTabBase):
|
|
|
569
571
|
are specific to this app.
|
|
570
572
|
"""
|
|
571
573
|
|
|
574
|
+
def __init__(self, MainWindow):
|
|
575
|
+
super().__init__(MainWindow)
|
|
576
|
+
|
|
577
|
+
# Tracks which action self.process is currently running, so on_finished() can tell a
|
|
578
|
+
# simulation run apart from a mesh-creation run (self.process is reused for both).
|
|
579
|
+
self._process_purpose = None
|
|
580
|
+
|
|
581
|
+
# "View in ParaView" is thermal-only (Palace/EM results are S-parameters, not a 3D
|
|
582
|
+
# field to open directly), added as its own row of the base class's buttons_grid so
|
|
583
|
+
# it lines up with Preview/Create Mesh/Start Simulation above, in the same Actions group.
|
|
584
|
+
row = self.buttons_grid.rowCount()
|
|
585
|
+
self.paraview_btn = QPushButton("🖼️ View in ParaView")
|
|
586
|
+
self.paraview_btn.clicked.connect(self.launch_paraview)
|
|
587
|
+
self.buttons_grid.addWidget(self.paraview_btn, row, 0)
|
|
588
|
+
|
|
589
|
+
def _append_thermal_results_summary(self):
|
|
590
|
+
# Parse thermal_results.dat / thermal_results.vtu and append a results summary
|
|
591
|
+
# to the log. Called from on_finished() after a real simulation run.
|
|
592
|
+
run_path = saved_values['sim_path'] + "/elmer_model/" + saved_values['model_basename'] + "_data"
|
|
593
|
+
|
|
594
|
+
# Heat sources first, then constant-temperature boundaries, regardless of the
|
|
595
|
+
# order they were added in the GUI.
|
|
596
|
+
rows = []
|
|
597
|
+
for thermal_object in thermal_objects.objects:
|
|
598
|
+
if isinstance(thermal_object, simulation_setup.heatsource):
|
|
599
|
+
rows.append(["Heat source", str(thermal_object.source_layernum),
|
|
600
|
+
str(thermal_object.target_layername), f"{thermal_object.power} W"])
|
|
601
|
+
for thermal_object in thermal_objects.objects:
|
|
602
|
+
if isinstance(thermal_object, simulation_setup.constanttemp):
|
|
603
|
+
rows.append(["Const. temp", str(thermal_object.source_layernum),
|
|
604
|
+
str(thermal_object.target_layername), f"{thermal_object.temp} K"])
|
|
605
|
+
source_lines = format_source_table(rows) if rows else None
|
|
606
|
+
|
|
607
|
+
summary = build_thermal_summary(run_path, source_lines=source_lines)
|
|
608
|
+
self.log_area.appendPlainText("\n" + summary + "\n")
|
|
609
|
+
|
|
610
|
+
def on_finished(self, exit_code, exit_status):
|
|
611
|
+
super().on_finished(exit_code, exit_status)
|
|
612
|
+
# Auto-append the results summary after a real simulation run (not after mesh creation)
|
|
613
|
+
if self._process_purpose == "run_simulation":
|
|
614
|
+
self._append_thermal_results_summary()
|
|
615
|
+
|
|
616
|
+
def launch_paraview(self):
|
|
617
|
+
run_path = saved_values['sim_path'] + "/elmer_model/" + saved_values['model_basename'] + "_data"
|
|
618
|
+
vtu_path = find_thermal_vtu(run_path)
|
|
619
|
+
self._open_in_paraview(
|
|
620
|
+
[vtu_path] if vtu_path else [],
|
|
621
|
+
f"⚠️ No thermal results .vtu found yet under {run_path}\n"
|
|
622
|
+
)
|
|
623
|
+
|
|
572
624
|
def create_model(self):
|
|
573
625
|
# Request all tabs to save values again,
|
|
574
626
|
# which can do some update to saved_values
|
|
@@ -606,6 +658,7 @@ class CreateModelTab(CreateModelTabBase):
|
|
|
606
658
|
|
|
607
659
|
# Run Python interpreter on that file
|
|
608
660
|
python_exe = sys.executable # Use the same Python interpreter
|
|
661
|
+
self._process_purpose = "create_mesh"
|
|
609
662
|
self.process.start(python_exe, [pymodel_filename])
|
|
610
663
|
|
|
611
664
|
|
|
@@ -622,6 +675,7 @@ class CreateModelTab(CreateModelTabBase):
|
|
|
622
675
|
|
|
623
676
|
# clear log
|
|
624
677
|
self.log_area.clear()
|
|
678
|
+
self._process_purpose = "run_simulation"
|
|
625
679
|
|
|
626
680
|
# try to start from output directory
|
|
627
681
|
run_path = saved_values['sim_path'] + "/elmer_model/" + saved_values['model_basename'] + "_data"
|
|
@@ -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, io, contextlib
|
|
35
|
+
import sys, os, json, pathlib, ast, webbrowser, io, contextlib, subprocess, shutil, glob
|
|
36
36
|
import xml.etree.ElementTree as ET
|
|
37
37
|
import numpy as np
|
|
38
38
|
import requests
|
|
@@ -1277,6 +1277,7 @@ class CreateModelTabBase(QWidget):
|
|
|
1277
1277
|
self.process.readyReadStandardOutput.connect(self.on_stdout)
|
|
1278
1278
|
self.process.readyReadStandardError.connect(self.on_stderr)
|
|
1279
1279
|
self.process.finished.connect(self.on_finished)
|
|
1280
|
+
self.process.errorOccurred.connect(self.on_process_error)
|
|
1280
1281
|
|
|
1281
1282
|
def on_modelname_edit_done(self):
|
|
1282
1283
|
# Model name edit field has changed
|
|
@@ -1298,6 +1299,64 @@ class CreateModelTabBase(QWidget):
|
|
|
1298
1299
|
"""Handle process completion."""
|
|
1299
1300
|
self.log_area.appendPlainText(f"\n--- Process finished with exit code {exit_code} ---\n")
|
|
1300
1301
|
|
|
1302
|
+
def on_process_error(self, error):
|
|
1303
|
+
"""Handle QProcess itself failing to launch or run - most importantly
|
|
1304
|
+
FailedToStart (program not found, or not executable, e.g. a .bat with content
|
|
1305
|
+
the current shell can't run). Without this, such failures were silent: none of
|
|
1306
|
+
readyReadStandardOutput/readyReadStandardError/finished ever fire for a process
|
|
1307
|
+
that never actually started, leaving the log blank with no indication anything
|
|
1308
|
+
went wrong.
|
|
1309
|
+
"""
|
|
1310
|
+
messages = {
|
|
1311
|
+
QProcess.FailedToStart: "the program could not be started (not found, or not executable)",
|
|
1312
|
+
QProcess.Crashed: "the process crashed",
|
|
1313
|
+
QProcess.Timedout: "the process timed out",
|
|
1314
|
+
QProcess.WriteError: "an error occurred while writing to the process",
|
|
1315
|
+
QProcess.ReadError: "an error occurred while reading from the process",
|
|
1316
|
+
QProcess.UnknownError: "an unknown error occurred",
|
|
1317
|
+
}
|
|
1318
|
+
detail = messages.get(error, f"error code {error}")
|
|
1319
|
+
self.log_area.appendPlainText(f"\n⚠️ Process error: {detail}\n")
|
|
1320
|
+
|
|
1321
|
+
def _open_in_paraview(self, file_paths, not_found_message):
|
|
1322
|
+
"""Locate ParaView and open it on file_paths (a list of .pvd/.vtu paths), or
|
|
1323
|
+
log not_found_message if file_paths is empty. Shared by setupEM.py (Palace
|
|
1324
|
+
field dumps / Elmer EM fields) and setupThermal.py (thermal_results.vtu) -
|
|
1325
|
+
each caller is responsible for finding its own result files and wording its
|
|
1326
|
+
own "nothing found" message; this only handles locating/launching ParaView.
|
|
1327
|
+
Detached, non-blocking launch (like klayout_setupEM.py's setupEM launch) -
|
|
1328
|
+
this is a fire-and-forget external GUI viewer, unrelated to self.process's
|
|
1329
|
+
solver-run lifecycle.
|
|
1330
|
+
"""
|
|
1331
|
+
if not file_paths:
|
|
1332
|
+
self.log_area.appendPlainText(not_found_message)
|
|
1333
|
+
return
|
|
1334
|
+
|
|
1335
|
+
paraview_exe = shutil.which("paraview")
|
|
1336
|
+
if paraview_exe is None and os.name == "nt":
|
|
1337
|
+
# Not on PATH: fall back to searching the usual install locations, newest first.
|
|
1338
|
+
candidates = (
|
|
1339
|
+
glob.glob(r"C:\Program Files\ParaView*\bin\paraview.exe")
|
|
1340
|
+
+ glob.glob(r"C:\Program Files (x86)\ParaView*\bin\paraview.exe")
|
|
1341
|
+
)
|
|
1342
|
+
if candidates:
|
|
1343
|
+
paraview_exe = max(candidates, key=os.path.getmtime)
|
|
1344
|
+
|
|
1345
|
+
if paraview_exe is None:
|
|
1346
|
+
self.log_area.appendPlainText(
|
|
1347
|
+
"⚠️ ParaView not found on PATH. Install it, or add it to PATH, "
|
|
1348
|
+
"then open manually:\n" + "\n".join(file_paths) + "\n"
|
|
1349
|
+
)
|
|
1350
|
+
return
|
|
1351
|
+
|
|
1352
|
+
env = os.environ.copy()
|
|
1353
|
+
env.pop("PYTHONHOME", None)
|
|
1354
|
+
try:
|
|
1355
|
+
subprocess.Popen([paraview_exe, *file_paths], env=env)
|
|
1356
|
+
self.log_area.appendPlainText("Starting ParaView on:\n" + "\n".join(file_paths) + "\n")
|
|
1357
|
+
except OSError as e:
|
|
1358
|
+
self.log_area.appendPlainText(f"⚠️ Failed to launch ParaView: {e}\n")
|
|
1359
|
+
|
|
1301
1360
|
def browse_directory(self):
|
|
1302
1361
|
directory = QFileDialog.getExistingDirectory(
|
|
1303
1362
|
self,
|
|
@@ -1328,9 +1387,15 @@ class CreateModelTabBase(QWidget):
|
|
|
1328
1387
|
if "===" in model_basename:
|
|
1329
1388
|
model_basename = ""
|
|
1330
1389
|
|
|
1331
|
-
#
|
|
1332
|
-
|
|
1333
|
-
|
|
1390
|
+
# Strip a stale simulator-name prefix left over from a different solver choice
|
|
1391
|
+
# (e.g. "elmer_..." after switching to Palace mode) - but never strip a prefix
|
|
1392
|
+
# that already matches the current choice, otherwise importing an existing
|
|
1393
|
+
# model and reusing its filename would silently rename it to a different file
|
|
1394
|
+
# (setupThermal has no PalaceMode attribute at all; it's always Elmer, so only
|
|
1395
|
+
# a "palace_" prefix would ever be considered stale there).
|
|
1396
|
+
palace_mode = getattr(self.MainWindow, 'PalaceMode', False)
|
|
1397
|
+
mismatched_prefix = 'elmer_' if palace_mode else 'palace_'
|
|
1398
|
+
model_basename = model_basename.replace(mismatched_prefix, '')
|
|
1334
1399
|
|
|
1335
1400
|
self.modelname_edit.setText(model_basename)
|
|
1336
1401
|
|
|
@@ -1708,7 +1773,11 @@ class MainWindowBase(QMainWindow):
|
|
|
1708
1773
|
if import_key not in import_value: # skip the section where key might appear in different context
|
|
1709
1774
|
# get the internal name for this variable
|
|
1710
1775
|
varname = import_mapping.get(import_key, '')
|
|
1711
|
-
if varname in ("fpoint", "fdump"
|
|
1776
|
+
if varname in ("fpoint", "fdump"):
|
|
1777
|
+
# same Hz-in-code / GHz-in-GUI unit split as fstart/fstop/fstep below,
|
|
1778
|
+
# just per-element since these are lists
|
|
1779
|
+
saved_values[varname] = [f / 1e9 for f in ast.literal_eval(import_value)]
|
|
1780
|
+
elif varname in ("variable_overrides", "refined_cellsize_override"):
|
|
1712
1781
|
saved_values[varname] = ast.literal_eval(import_value)
|
|
1713
1782
|
elif varname in ["gds_filename", "XML_filename", "GdsFile", "SubstrateFile"]:
|
|
1714
1783
|
# check if we have full path for files in imported Python script,
|
|
@@ -0,0 +1,175 @@
|
|
|
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
|
+
"""Parse Elmer thermal solver output (thermal_results.dat / thermal_results.vtu)
|
|
20
|
+
into a human-readable summary for the setupThermal "Create Model" log. No Qt
|
|
21
|
+
dependency, so this can be exercised standalone against real Elmer output
|
|
22
|
+
directories.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
import os
|
|
26
|
+
import glob
|
|
27
|
+
|
|
28
|
+
import meshio
|
|
29
|
+
|
|
30
|
+
# Elmer's Post File in util_elmer.py is written with "Coordinate Scaling = Real 1e-6"
|
|
31
|
+
# applied on read-in (mesh coordinates in um are scaled down to meters for the solve),
|
|
32
|
+
# so node coordinates recovered from the .vtu are in meters and need to be scaled back
|
|
33
|
+
# up to um to match the layout's native units.
|
|
34
|
+
_METERS_TO_UM = 1e6
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _candidate_dirs(run_path):
|
|
38
|
+
# util_elmer.py writes "Filename = ../thermal_results.dat" and
|
|
39
|
+
# "Post File = ../thermal_results.vtu", which reads as "one level above run_path"
|
|
40
|
+
# -- but Elmer actually resolves both relative to the Mesh DB directory ("mesh"
|
|
41
|
+
# under run_path, per "Mesh DB \"mesh\" \".\"" in the .sif), so "../" cancels back
|
|
42
|
+
# out to run_path itself (confirmed via thermal_results.dat.names: "Metadata for
|
|
43
|
+
# SaveScalars file: mesh/../thermal_results.dat"). run_path is therefore the real
|
|
44
|
+
# location; its parent is kept as a defensive fallback for other Elmer builds/versions.
|
|
45
|
+
run_path = os.path.normpath(run_path)
|
|
46
|
+
return [run_path, os.path.dirname(run_path)]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def find_thermal_dat(run_path):
|
|
50
|
+
"""Return the path to thermal_results.dat, or None if it doesn't exist yet."""
|
|
51
|
+
for d in _candidate_dirs(run_path):
|
|
52
|
+
path = os.path.join(d, "thermal_results.dat")
|
|
53
|
+
if os.path.isfile(path):
|
|
54
|
+
return path
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def find_thermal_vtu(run_path):
|
|
59
|
+
"""Return the path to the thermal results .vtu file, or None if it doesn't exist yet.
|
|
60
|
+
|
|
61
|
+
Elmer's Post File writer appends a timestep suffix (e.g. thermal_results_t0001.vtu)
|
|
62
|
+
even for a single steady-state solve, so this globs rather than matching a fixed name;
|
|
63
|
+
if more than one is found (re-run, or a multi-iteration solve), the most recently
|
|
64
|
+
written one wins.
|
|
65
|
+
"""
|
|
66
|
+
matches = []
|
|
67
|
+
for d in _candidate_dirs(run_path):
|
|
68
|
+
matches += glob.glob(os.path.join(d, "thermal_results*.vtu"))
|
|
69
|
+
if not matches:
|
|
70
|
+
return None
|
|
71
|
+
return max(matches, key=os.path.getmtime)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def read_minmax_temperature(run_path):
|
|
75
|
+
"""Return (t_min, t_max) from thermal_results.dat, or None if missing/unparsable."""
|
|
76
|
+
path = find_thermal_dat(run_path)
|
|
77
|
+
if path is None:
|
|
78
|
+
return None
|
|
79
|
+
try:
|
|
80
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
81
|
+
lines = [line.strip() for line in f if line.strip()]
|
|
82
|
+
if not lines:
|
|
83
|
+
return None
|
|
84
|
+
# SaveScalars writes one row per solver call; steady-state thermal is a single
|
|
85
|
+
# solve, but take the last line defensively in case Elmer ever appends more.
|
|
86
|
+
values = [float(v) for v in lines[-1].split()]
|
|
87
|
+
if len(values) < 2:
|
|
88
|
+
return None
|
|
89
|
+
# Column order is fixed by our own .sif generation: Operator 1 = min, Operator 2 = max.
|
|
90
|
+
return values[0], values[1]
|
|
91
|
+
except (OSError, ValueError):
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def find_max_temperature_location(run_path):
|
|
96
|
+
"""Return (x, y, z) in um of the mesh node with the highest temperature, or None
|
|
97
|
+
if the thermal results .vtu doesn't exist yet or can't be read/parsed.
|
|
98
|
+
"""
|
|
99
|
+
path = find_thermal_vtu(run_path)
|
|
100
|
+
if path is None:
|
|
101
|
+
return None
|
|
102
|
+
try:
|
|
103
|
+
mesh = meshio.read(path)
|
|
104
|
+
temp_key = next(
|
|
105
|
+
(key for key in mesh.point_data if "temp" in key.lower()),
|
|
106
|
+
None,
|
|
107
|
+
)
|
|
108
|
+
if temp_key is None:
|
|
109
|
+
return None
|
|
110
|
+
temperature = mesh.point_data[temp_key]
|
|
111
|
+
max_index = temperature.argmax()
|
|
112
|
+
x, y, z = mesh.points[max_index]
|
|
113
|
+
return x * _METERS_TO_UM, y * _METERS_TO_UM, z * _METERS_TO_UM
|
|
114
|
+
except Exception:
|
|
115
|
+
# Any read/parse failure (unsupported VTU variant, half-written file while a
|
|
116
|
+
# simulation is still running, etc.) should never block the min/max summary.
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
_SOURCE_TABLE_HEADERS = ["Type", "GDS Layer", "Target Layer", "Value"]
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def format_source_table(rows):
|
|
124
|
+
"""Format (type, gds_layer, target_layer, value) string rows into an aligned table,
|
|
125
|
+
same layout style as palace_results.py's AMR results table. The caller decides row
|
|
126
|
+
order (heat sources first, then constant-temperature boundaries, per setupThermal.py).
|
|
127
|
+
"""
|
|
128
|
+
headers = _SOURCE_TABLE_HEADERS
|
|
129
|
+
widths = [max(len(headers[i]), *(len(r[i]) for r in rows)) for i in range(len(headers))]
|
|
130
|
+
|
|
131
|
+
def fmt_row(cells):
|
|
132
|
+
return " | ".join(
|
|
133
|
+
cell.rjust(widths[i]) if i == len(cells) - 1 else cell.ljust(widths[i])
|
|
134
|
+
for i, cell in enumerate(cells)
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
lines = [fmt_row(headers), "-+-".join("-" * w for w in widths)]
|
|
138
|
+
lines += [fmt_row(row) for row in rows]
|
|
139
|
+
return lines
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def build_thermal_summary(run_path, source_lines=None):
|
|
143
|
+
"""Return a formatted multi-line summary of Elmer thermal results found relative
|
|
144
|
+
to run_path (the case.sif working directory), or an explanatory message if
|
|
145
|
+
nothing is there yet.
|
|
146
|
+
|
|
147
|
+
source_lines, if given, is a list of pre-formatted strings describing the model's
|
|
148
|
+
heat sources / constant-temperature boundaries (e.g. str(heatsource_instance)) --
|
|
149
|
+
this module only knows about Elmer's output files, not the GUI's in-memory model,
|
|
150
|
+
so the caller supplies these rather than this module importing simulation_setup.
|
|
151
|
+
"""
|
|
152
|
+
minmax = read_minmax_temperature(run_path)
|
|
153
|
+
if minmax is None:
|
|
154
|
+
return (f"No thermal results found yet (expected thermal_results.dat under {run_path}) "
|
|
155
|
+
"-- has the simulation finished?")
|
|
156
|
+
t_min, t_max = minmax
|
|
157
|
+
|
|
158
|
+
location = find_max_temperature_location(run_path)
|
|
159
|
+
if location is not None:
|
|
160
|
+
x, y, z = location
|
|
161
|
+
location_line = f"Hotspot location : x={x:.3f} um y={y:.3f} um z={z:.3f} um"
|
|
162
|
+
else:
|
|
163
|
+
location_line = "Hotspot location : n/a (see thermal_results.vtu)"
|
|
164
|
+
|
|
165
|
+
lines = ["=== Thermal results ==="]
|
|
166
|
+
if source_lines:
|
|
167
|
+
lines.extend(source_lines)
|
|
168
|
+
lines.append("-" * 40)
|
|
169
|
+
lines += [
|
|
170
|
+
f"Min temperature : {t_min:.2f} K",
|
|
171
|
+
f"Max temperature : {t_max:.2f} K",
|
|
172
|
+
location_line,
|
|
173
|
+
"=" * 40,
|
|
174
|
+
]
|
|
175
|
+
return "\n".join(lines)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: setupEM
|
|
3
|
-
Version: 0.6.
|
|
3
|
+
Version: 0.6.1
|
|
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
|
|
@@ -15,6 +15,7 @@ Requires-Dist: scikit-rf
|
|
|
15
15
|
Requires-Dist: matplotlib
|
|
16
16
|
Requires-Dist: numpy
|
|
17
17
|
Requires-Dist: gdspy
|
|
18
|
+
Requires-Dist: meshio
|
|
18
19
|
Dynamic: license-file
|
|
19
20
|
|
|
20
21
|
# Python GUI for gds2palace
|
|
@@ -12,6 +12,7 @@ src/setupEM/setupThermal.py
|
|
|
12
12
|
src/setupEM/setup_common.py
|
|
13
13
|
src/setupEM/stackupEditor.py
|
|
14
14
|
src/setupEM/stackup_writer.py
|
|
15
|
+
src/setupEM/thermal_results.py
|
|
15
16
|
src/setupEM.egg-info/PKG-INFO
|
|
16
17
|
src/setupEM.egg-info/SOURCES.txt
|
|
17
18
|
src/setupEM.egg-info/dependency_links.txt
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|