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.
Files changed (27) hide show
  1. {setupem-0.6.0/src/setupEM.egg-info → setupem-0.6.1}/PKG-INFO +2 -1
  2. {setupem-0.6.0 → setupem-0.6.1}/pyproject.toml +2 -1
  3. {setupem-0.6.0 → setupem-0.6.1}/src/setupEM/__init__.py +1 -1
  4. {setupem-0.6.0 → setupem-0.6.1}/src/setupEM/palace_results.py +18 -0
  5. {setupem-0.6.0 → setupem-0.6.1}/src/setupEM/setupEM.py +104 -27
  6. {setupem-0.6.0 → setupem-0.6.1}/src/setupEM/setupThermal.py +54 -0
  7. {setupem-0.6.0 → setupem-0.6.1}/src/setupEM/setup_common.py +74 -5
  8. setupem-0.6.1/src/setupEM/thermal_results.py +175 -0
  9. {setupem-0.6.0 → setupem-0.6.1/src/setupEM.egg-info}/PKG-INFO +2 -1
  10. {setupem-0.6.0 → setupem-0.6.1}/src/setupEM.egg-info/SOURCES.txt +1 -0
  11. {setupem-0.6.0 → setupem-0.6.1}/src/setupEM.egg-info/requires.txt +1 -0
  12. {setupem-0.6.0 → setupem-0.6.1}/LICENSE +0 -0
  13. {setupem-0.6.0 → setupem-0.6.1}/README.md +0 -0
  14. {setupem-0.6.0 → setupem-0.6.1}/README_pypi.md +0 -0
  15. {setupem-0.6.0 → setupem-0.6.1}/setup.cfg +0 -0
  16. {setupem-0.6.0 → setupem-0.6.1}/src/setupEM/__main__.py +0 -0
  17. {setupem-0.6.0 → setupem-0.6.1}/src/setupEM/data/SG13G2_100um.xml +0 -0
  18. {setupem-0.6.0 → setupem-0.6.1}/src/setupEM/data/SG13G2_200um.xml +0 -0
  19. {setupem-0.6.0 → setupem-0.6.1}/src/setupEM/data/SG13G2_200um_with_parameters.xml +0 -0
  20. {setupem-0.6.0 → setupem-0.6.1}/src/setupEM/data/SG13G2_nosub.xml +0 -0
  21. {setupem-0.6.0 → setupem-0.6.1}/src/setupEM/momentum_import.py +0 -0
  22. {setupem-0.6.0 → setupem-0.6.1}/src/setupEM/result_viewer.py +0 -0
  23. {setupem-0.6.0 → setupem-0.6.1}/src/setupEM/stackupEditor.py +0 -0
  24. {setupem-0.6.0 → setupem-0.6.1}/src/setupEM/stackup_writer.py +0 -0
  25. {setupem-0.6.0 → setupem-0.6.1}/src/setupEM.egg-info/dependency_links.txt +0 -0
  26. {setupem-0.6.0 → setupem-0.6.1}/src/setupEM.egg-info/entry_points.txt +0 -0
  27. {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.0
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
@@ -19,7 +19,8 @@ dependencies = [
19
19
  "scikit-rf",
20
20
  "matplotlib",
21
21
  "numpy",
22
- "gdspy"
22
+ "gdspy",
23
+ "meshio"
23
24
  ]
24
25
  requires-python = ">=3.9"
25
26
 
@@ -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.6.0"
27
+ __version__ = "0.6.1"
@@ -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 Results...")
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 has no such subdirectory - its solver
1435
- writes scalar_results.*/fields*.vtu*/*.sNp files directly into run_path
1436
- alongside the mesh/config - so those are matched and removed individually.
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
- if os.path.isdir(run_path):
1446
- for fn in os.listdir(run_path):
1447
- full_path = os.path.join(run_path, fn)
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.dat", "scalar_results.names") or \
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
- # rename file to batch file, but only if that has not already
1550
- # been done by a previous run (otherwise os.rename() fails on
1551
- # the second run because run_elmer no longer exists)
1552
- run_file_orig = os.path.join(run_path, 'run_elmer')
1553
- run_file = run_file_orig.replace('run_elmer','run_elmer.bat')
1554
- if os.path.exists(run_file_orig):
1555
- os.rename(run_file_orig, run_file)
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
- self.process.start("run_elmer.bat")
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
- self.frequencies_tab.dump_group.setVisible(False)
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 is expressed only by which simulation_setup.create_*()
1973
- # call the script makes, never as a literal settings['elmer'] assignment
1974
- # (parse_assignments() skips lines whose value contains "settings"), so
1975
- # detect it directly from the source text instead.
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
- if "create_elmer(" in text:
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
- # no simulator name prefix
1332
- model_basename = model_basename.replace('palace_', '')
1333
- model_basename = model_basename.replace('elmer_', '')
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", "variable_overrides", "refined_cellsize_override"):
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.0
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
@@ -6,3 +6,4 @@ scikit-rf
6
6
  matplotlib
7
7
  numpy
8
8
  gdspy
9
+ meshio
File without changes
File without changes
File without changes
File without changes
File without changes