palace-toolkit 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,29 @@
1
+ Metadata-Version: 2.4
2
+ Name: palace-toolkit
3
+ Version: 0.1.0
4
+ Summary: PALACE electromagnetic simulation course materials
5
+ Requires-Python: >=3.8
6
+ Requires-Dist: gmsh
7
+ Requires-Dist: numpy
8
+ Requires-Dist: scipy
9
+ Requires-Dist: meshio
10
+ Requires-Dist: mfem
11
+ Requires-Dist: pyvista
12
+ Requires-Dist: enlighten
13
+ Requires-Dist: pint
14
+ Provides-Extra: plot
15
+ Requires-Dist: pandas; extra == "plot"
16
+ Requires-Dist: matplotlib; extra == "plot"
17
+ Provides-Extra: palace-cpu
18
+ Requires-Dist: palacetoolkit-palace-cpu>=0.1.0; extra == "palace-cpu"
19
+ Provides-Extra: docs
20
+ Requires-Dist: mkdocs; extra == "docs"
21
+ Requires-Dist: mkdocs-material; extra == "docs"
22
+ Requires-Dist: mkdocs-jupyter; extra == "docs"
23
+ Requires-Dist: pyvista[jupyter]; extra == "docs"
24
+ Requires-Dist: nbconvert; extra == "docs"
25
+ Requires-Dist: ipykernel; extra == "docs"
26
+ Requires-Dist: papermill; extra == "docs"
27
+ Requires-Dist: pytest; extra == "docs"
28
+ Requires-Dist: nb-clean; extra == "docs"
29
+ Requires-Dist: pre-commit; extra == "docs"
@@ -0,0 +1,16 @@
1
+ palacetoolkit/__init__.py,sha256=LIoHyIWZYLD3Z6ttthkp1xMb2Xbpmj7hLzEK_wKVwmg,1008
2
+ palacetoolkit/analytic.py,sha256=5EWEfOOpO4i4Ag_vkNV9sqn_eNA10Ip1S-Z0njERPAo,1821
3
+ palacetoolkit/geometry.py,sha256=due6wyFo8v2a0YLXfS5fyo0L8ML_X1a4B92mbUlZHkM,1353
4
+ palacetoolkit/mesh.py,sha256=s63qoFD6o3POcoxs7u8BeX0ziK8_t2CsseZ6MpXJKzA,18627
5
+ palacetoolkit/mode_solver.py,sha256=7epK5U7tmRPLd84ystXUA_4JBX66FjivGIVO3q0S3f8,34406
6
+ palacetoolkit/palace_runtime.py,sha256=olMBIgLAlGQLSOEFAobktZ1_QlBL1MNjR4CoLse6VBk,1249
7
+ palacetoolkit/plot_farfield.py,sha256=xNBZNe6N1veYjhgP1N0k1pZqAIe08FllJ9npOvqxYHo,10544
8
+ palacetoolkit/s_plot.py,sha256=Fx4Q1dTeYH-QDfj-jJiQ3k5DN2zYYY9abBih2O6wNDQ,902
9
+ palacetoolkit/simulation.py,sha256=G_KLAqXypkN_m_7fk4uvpJm90EwPps_7VuS25mEB2es,16545
10
+ palacetoolkit/utils.py,sha256=BjcbXMmfX63Y_oAqXDHK7SNTtJRo6IpGjIGp590Pe98,15334
11
+ palacetoolkit/verify_topology.py,sha256=ulU4fXknKMmVHIwV0YlHekXwq-y4mFj-csoMZXlmbtc,34348
12
+ palacetoolkit/viz.py,sha256=oKZs6ck3blTGlDmJ2z9mNw34xNY3KEPIvdklJhk7GFA,35640
13
+ palace_toolkit-0.1.0.dist-info/METADATA,sha256=QH4zbRG64Sxu3aAi9wntXGMSoVw1ytStoNizIza2bJg,966
14
+ palace_toolkit-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
15
+ palace_toolkit-0.1.0.dist-info/top_level.txt,sha256=3xE5XaV7aN2_Hf6IHJ3OJGEV1pSO9fkvLW2b2j4U7e8,14
16
+ palace_toolkit-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ palacetoolkit
@@ -0,0 +1,31 @@
1
+ """PalaceToolkit — utilities for Palace electromagnetic simulations.
2
+
3
+ Import as::
4
+
5
+ from palacetoolkit.analytic import cpw_impedance
6
+ from palacetoolkit.mesh import Entity, run_meshing_pipeline
7
+ from palacetoolkit.simulation import run_palace, generate_palace_config
8
+ from palacetoolkit.viz import view_mesh
9
+ from palacetoolkit.verify_topology import analyse_mesh
10
+ """
11
+
12
+ from palacetoolkit.analytic import * # noqa: F401,F403
13
+ from palacetoolkit.mesh import * # noqa: F401,F403
14
+ from palacetoolkit.simulation import * # noqa: F401,F403
15
+ from palacetoolkit.verify_topology import * # noqa: F401,F403
16
+
17
+ # Optional submodules — silently skip when extra deps are missing
18
+ try:
19
+ from palacetoolkit.s_plot import * # noqa: F401,F403
20
+ except ImportError:
21
+ pass
22
+
23
+ try:
24
+ from palacetoolkit.viz import * # noqa: F401,F403
25
+ except ImportError:
26
+ pass
27
+
28
+ try:
29
+ from palacetoolkit.utils import * # noqa: F401,F403
30
+ except ImportError:
31
+ pass
@@ -0,0 +1,60 @@
1
+ """Analytic closed-form expressions for transmission line parameters."""
2
+
3
+ import numpy as np
4
+
5
+ def _K_over_Kp(k: float, kp: float) -> float:
6
+ """Approximate ratio K(k)/K(k') using the Hilberg formula."""
7
+ def _s(x: float) -> float:
8
+ return np.log(
9
+ 2 * (np.sqrt(1 + x) + (4 * x) ** 0.25)
10
+ / (np.sqrt(1 + x) - (4 * x) ** 0.25)
11
+ )
12
+
13
+ if k >= 1.0 / np.sqrt(2):
14
+ return _s(k) / (2 * np.pi)
15
+ else:
16
+ return 2 * np.pi / _s(kp)
17
+
18
+ def cpw_impedance(w: float, s: float, h: float, eps_r: float) -> float:
19
+ """Compute the characteristic impedance of a CPW line.
20
+
21
+ Uses the conformal mapping approach with elliptic integral ratios K(k)/K(k').
22
+
23
+ Args:
24
+ w: Centre conductor width.
25
+ s: Gap between centre conductor and ground.
26
+ h: Substrate thickness.
27
+ eps_r: Relative permittivity of the substrate.
28
+
29
+ Returns:
30
+ Z0: Characteristic impedance in Ohms.
31
+ """
32
+ k = w / (w + 2 * s)
33
+ k1 = np.sinh(np.pi * w / (4 * h)) / np.sinh(np.pi * (w + 2 * s) / (4 * h))
34
+
35
+ kp = np.sqrt(1 - k ** 2)
36
+ k1p = np.sqrt(1 - k1 ** 2)
37
+
38
+ kok = _K_over_Kp(k, kp)
39
+ k1ok = _K_over_Kp(k1, k1p)
40
+
41
+ eps_eff = 1 + ((eps_r - 1) / 2) * k1ok / kok
42
+ Z0 = 30 * np.pi / (kok * np.sqrt(eps_eff))
43
+ return Z0
44
+
45
+
46
+ def cpw_effective_index(w: float, s: float, h: float, eps_r: float) -> float:
47
+ """Return the effective refractive index of a CPW line.
48
+
49
+ Uses the same conformal-mapping model as :func:`cpw_impedance`.
50
+ """
51
+ k = w / (w + 2 * s)
52
+ k1 = np.sinh(np.pi * w / (4 * h)) / np.sinh(np.pi * (w + 2 * s) / (4 * h))
53
+
54
+ kp = np.sqrt(1 - k ** 2)
55
+ k1p = np.sqrt(1 - k1 ** 2)
56
+ kok = _K_over_Kp(k, kp)
57
+ k1ok = _K_over_Kp(k1, k1p)
58
+
59
+ eps_eff = 1 + ((eps_r - 1) / 2) * k1ok / kok
60
+ return np.sqrt(eps_eff)
@@ -0,0 +1,48 @@
1
+ import gmsh
2
+
3
+ def extract_tag(obj):
4
+ """
5
+ Extract the Gmsh tag from an object.
6
+
7
+ If object contains only one tag, return it as an integer, otherwise, preserve its
8
+ container.
9
+
10
+ Most gmsh functions return list of tuples like [(2, 5), (2, 8), (2, 10), ...], where the
11
+ first number is dimensionality and the second is the integer tag associated to that object.
12
+
13
+ Examples
14
+ --------
15
+ >>> extract_tag((3, 6))
16
+ 6
17
+
18
+ >>> entities = [(2, 5), (2, 8), (2, 10)]
19
+ >>> [extract_tag(e) for e in entities]
20
+ [5, 8, 10]
21
+ """
22
+
23
+ if isinstance(obj, list) and len(obj) == 1:
24
+ return extract_tag(obj[0])
25
+ elif isinstance(obj, tuple):
26
+ return obj[1]
27
+ else:
28
+ raise ValueError("Expected a tuple or single-element list")
29
+
30
+
31
+ # Convenience functions to extract extrema of the bounding box of an entity
32
+ def xmin(dimtag):
33
+ return gmsh.model.occ.getBoundingBox(dimtag[0], dimtag[1])[0]
34
+
35
+ def ymin(dimtag):
36
+ return gmsh.model.occ.getBoundingBox(dimtag[0], dimtag[1])[1]
37
+
38
+ def zmin(dimtag):
39
+ return gmsh.model.occ.getBoundingBox(dimtag[0], dimtag[1])[2]
40
+
41
+ def xmax(dimtag):
42
+ return gmsh.model.occ.getBoundingBox(dimtag[0], dimtag[1])[3]
43
+
44
+ def ymax(dimtag):
45
+ return gmsh.model.occ.getBoundingBox(dimtag[0], dimtag[1])[4]
46
+
47
+ def zmax(dimtag):
48
+ return gmsh.model.occ.getBoundingBox(dimtag[0], dimtag[1])[5]
palacetoolkit/mesh.py ADDED
@@ -0,0 +1,468 @@
1
+ """Gmsh utilities: boolean pipeline, mesh generation helpers."""
2
+
3
+ import gmsh
4
+
5
+
6
+ # ---------------------------------------------------------------------------
7
+ # Minimalistic meshwell-style entity + pipeline
8
+ # ---------------------------------------------------------------------------
9
+
10
+ class Entity:
11
+ """A named geometric entity with priority (mesh_order) and tracked dimtags."""
12
+
13
+ def __init__(self, name: str, dim: int, mesh_order: int, tags: list[int]):
14
+ self.name = name
15
+ self.dim = dim
16
+ self.mesh_order = mesh_order
17
+ self.dimtags = [(dim, t) for t in tags]
18
+
19
+ def __repr__(self):
20
+ return f"Entity({self.name!r}, dim={self.dim}, order={self.mesh_order}, tags={[t for _, t in self.dimtags]})"
21
+
22
+
23
+ def run_meshing_pipeline(entities: list[Entity]):
24
+ """
25
+ Meshwell-style boolean pipeline (minimalistic).
26
+
27
+ 1. Group entities by dimension (descending: 3 -> 0).
28
+ 2. Within each dimension, sort by mesh_order (ascending = higher priority).
29
+ 3. Priority cuts: each entity is cut by all previously processed entities
30
+ in the same dimension (removeObject=True, removeTool=False).
31
+ 4. Fragment current dimension against all higher-dimension entities
32
+ already processed, then update tags via the returned mapping.
33
+ 5. Accumulate processed entities for the next (lower) dimension.
34
+ """
35
+ gmsh.model.occ.synchronize()
36
+
37
+ # Group by dimension
38
+ dim_groups: dict[int, list[Entity]] = {3: [], 2: [], 1: [], 0: []}
39
+ for e in entities:
40
+ dim_groups[e.dim].append(e)
41
+
42
+ # Sort each group by mesh_order (ascending = higher priority first)
43
+ for dim in dim_groups:
44
+ dim_groups[dim].sort(key=lambda e: e.mesh_order)
45
+
46
+ processed_higher_dims: list[Entity] = []
47
+
48
+ for dim in (3, 2, 1, 0):
49
+ current_group = dim_groups[dim]
50
+ if not current_group:
51
+ continue
52
+
53
+ # Move the fragment-against-higher-dims step to run BEFORE priority cuts so that all surface tags are refreshed from the fragment output map before any cut is attempted.
54
+
55
+ # --- A. Priority cuts within same dimension ---
56
+ processed_in_dim: list[Entity] = []
57
+ for entity in current_group:
58
+ # Collect tool dimtags from all previously processed entities in this dim
59
+ tool_dimtags = [dt for prev in processed_in_dim for dt in prev.dimtags]
60
+
61
+ if tool_dimtags and entity.dimtags:
62
+ cut_result, _ = gmsh.model.occ.cut(
63
+ entity.dimtags,
64
+ tool_dimtags,
65
+ removeObject=True,
66
+ removeTool=False,
67
+ )
68
+ gmsh.model.occ.synchronize()
69
+ entity.dimtags = list(set(cut_result))
70
+
71
+ if entity.dimtags:
72
+ processed_in_dim.append(entity)
73
+
74
+ # --- B. Fragment against higher dimensions ---
75
+ if processed_higher_dims and processed_in_dim:
76
+ object_dimtags = [dt for e in processed_in_dim for dt in e.dimtags]
77
+ tool_dimtags = [dt for e in processed_higher_dims for dt in e.dimtags]
78
+
79
+ _, out_map = gmsh.model.occ.fragment(
80
+ object_dimtags,
81
+ tool_dimtags,
82
+ removeObject=True,
83
+ removeTool=True,
84
+ )
85
+ gmsh.model.occ.synchronize()
86
+
87
+ # Update tags using the mapping (order: objects first, then tools)
88
+ idx = 0
89
+ for entity in processed_in_dim:
90
+ new_dimtags = []
91
+ for _ in entity.dimtags:
92
+ new_dimtags.extend(out_map[idx])
93
+ idx += 1
94
+ entity.dimtags = list(set(new_dimtags))
95
+
96
+ for entity in processed_higher_dims:
97
+ new_dimtags = []
98
+ for _ in entity.dimtags:
99
+ new_dimtags.extend(out_map[idx])
100
+ idx += 1
101
+ entity.dimtags = list(set(new_dimtags))
102
+
103
+ processed_higher_dims.extend(processed_in_dim)
104
+
105
+ # --- Assign physical groups ---
106
+
107
+ # 1. Volume physical groups (dim=3 only; dim=2 handled in step 3)
108
+ for entity in entities:
109
+ if entity.dim == 3 and entity.dimtags:
110
+ tags = [t for d, t in entity.dimtags if d == 3]
111
+ if tags:
112
+ pg_tag = gmsh.model.addPhysicalGroup(3, tags, name=entity.name)
113
+ print(f" Physical group '{entity.name}' (dim=3): pg={pg_tag}, tags={tags}")
114
+
115
+ # 2. Surface physical groups from volume boundaries
116
+ # Build a map: surface_tag -> list of volume entity names that own it
117
+ vol_entities = [e for e in entities if e.dim == 3 and e.dimtags]
118
+ surf_to_names: dict[int, list[str]] = {}
119
+ for entity in vol_entities:
120
+ for dt in entity.dimtags:
121
+ boundary = gmsh.model.getBoundary(
122
+ [dt], combined=False, oriented=False, recursive=False
123
+ )
124
+ for bdim, btag in boundary:
125
+ if bdim == 2:
126
+ btag = abs(btag)
127
+ surf_to_names.setdefault(btag, [])
128
+ if entity.name not in surf_to_names[btag]:
129
+ surf_to_names[btag].append(entity.name)
130
+
131
+ # 3. 2D entities keep their own name as the physical group
132
+ surf_entities = [e for e in entities if e.dim == 2 and e.dimtags]
133
+ assigned_surfs: set[int] = set()
134
+ for entity in surf_entities:
135
+ tags = [t for d, t in entity.dimtags if d == 2]
136
+ if tags:
137
+ pg_tag = gmsh.model.addPhysicalGroup(2, tags, name=entity.name)
138
+ print(f" Physical group '{entity.name}' (dim=2): pg={pg_tag}, tags={tags}")
139
+ assigned_surfs.update(abs(t) for t in tags)
140
+
141
+ # 4. Group remaining surfaces by their sorted owner name combination
142
+ name_combo_to_surfs: dict[str, list[int]] = {}
143
+ for stag, names in surf_to_names.items():
144
+ if stag in assigned_surfs:
145
+ continue
146
+ if len(names) == 1:
147
+ label = f"{names[0]}__None"
148
+ else:
149
+ label = "__".join(sorted(names))
150
+ name_combo_to_surfs.setdefault(label, []).append(stag)
151
+
152
+ for label, stags in name_combo_to_surfs.items():
153
+ pg_tag = gmsh.model.addPhysicalGroup(2, stags, name=label)
154
+ print(f" Physical group '{label}' (dim=2): pg={pg_tag}, tags={stags}")
155
+
156
+ # Build pg_map by reading back from gmsh (authoritative source for .msh tags)
157
+ pg_map: dict[str, int] = {}
158
+ for dim, pg_tag in gmsh.model.getPhysicalGroups():
159
+ name = gmsh.model.getPhysicalName(dim, pg_tag)
160
+ if name:
161
+ pg_map[name] = pg_tag
162
+
163
+ return pg_map
164
+
165
+
166
+ # ---------------------------------------------------------------------------
167
+ # 3D mesh + Palace config helpers
168
+ # ---------------------------------------------------------------------------
169
+
170
+ def generate_3d_mesh(
171
+ entities: list[Entity],
172
+ mesh_sizes: dict[str, float],
173
+ output_file: str,
174
+ optimize: bool = True,
175
+ ) -> None:
176
+ """Set mesh sizes, generate and write a 3D mesh.
177
+
178
+ Args:
179
+ entities: list of Entity objects with ``.name`` and ``.dimtags``.
180
+ mesh_sizes: mapping from entity name → characteristic length.
181
+ output_file: path for the output .msh file.
182
+ optimize: whether to run Netgen optimisation (disable for complex
183
+ imported CAD to avoid segfaults in thin-volume meshes).
184
+ """
185
+
186
+
187
+ def _apply_point_sizes(*, use_entity_tags: bool = True) -> int:
188
+ applied = 0
189
+ for entity in entities:
190
+ lc = mesh_sizes.get(entity.name)
191
+ if lc is None:
192
+ continue
193
+ if use_entity_tags:
194
+ dimtags = entity.dimtags
195
+ else:
196
+ dimtags = []
197
+ for dim, pg_tag in gmsh.model.getPhysicalGroups(entity.dim):
198
+ pg_name = gmsh.model.getPhysicalName(dim, pg_tag)
199
+ if pg_name == entity.name:
200
+ dimtags.extend((entity.dim, t) for t in gmsh.model.getEntitiesForPhysicalGroup(dim, pg_tag))
201
+
202
+ if not dimtags:
203
+ continue
204
+
205
+ try:
206
+ pts = gmsh.model.getBoundary(
207
+ dimtags, combined=False, oriented=False, recursive=True
208
+ )
209
+ except Exception:
210
+ continue
211
+
212
+ point_dimtags = [(d, t) for d, t in pts if d == 0]
213
+ if point_dimtags:
214
+ gmsh.model.mesh.setSize(point_dimtags, lc)
215
+ applied += 1
216
+
217
+ return applied
218
+
219
+ _apply_point_sizes(use_entity_tags=True)
220
+
221
+
222
+ try:
223
+ gmsh.model.mesh.generate(3)
224
+ except Exception as exc:
225
+ msg = str(exc).lower()
226
+ if "invalid boundary mesh" not in msg and "overlapping facets" not in msg:
227
+ raise
228
+
229
+ print(" Meshing failed with overlapping facets; attempting OCC repair + retry...")
230
+ gmsh.model.mesh.clear()
231
+ try:
232
+ gmsh.model.occ.removeAllDuplicates()
233
+ except Exception:
234
+ pass
235
+ try:
236
+ gmsh.model.occ.healShapes()
237
+ except Exception:
238
+ pass
239
+ gmsh.model.occ.synchronize()
240
+
241
+ applied = _apply_point_sizes(use_entity_tags=False)
242
+ if applied == 0 and mesh_sizes:
243
+ # Last-resort sizing when entity tags are no longer valid after repair.
244
+ gmsh.model.mesh.setSize(gmsh.model.getEntities(0), min(mesh_sizes.values()))
245
+
246
+ gmsh.model.mesh.generate(3)
247
+
248
+ if optimize:
249
+ gmsh.model.mesh.optimize("Netgen")
250
+ gmsh.option.setNumber("Mesh.MshFileVersion", 2.2)
251
+ gmsh.write(output_file)
252
+ print(f"Mesh saved to {output_file}")
253
+ print(f" Nodes: {len(gmsh.model.mesh.getNodes()[0])}")
254
+ elems = gmsh.model.mesh.getElements()
255
+ print(f" Elements: {sum(len(e) for e in elems[1])}")
256
+
257
+
258
+ def refine_near_surfaces(
259
+ surface_dimtags: list[tuple[int, int]],
260
+ wavelength: float,
261
+ ppw_near: int = 20,
262
+ ppw_far: int = 5,
263
+ transition_distance: float | None = None,
264
+ set_as_background: bool = True,
265
+ local_refinements: dict[tuple[int, int], int] | None = None,
266
+ # e.g. {(2, port_tag): 40, (2, other_tag): 60}
267
+ ) -> int:
268
+ """Set up mesh refinement that clusters elements near conductor boundary curves.
269
+
270
+ Creates Distance + Threshold background mesh fields so that elements are
271
+ finest (``wavelength / ppw_near``) right at the boundary curves of the
272
+ given surfaces and grade quickly to the coarse size
273
+ (``wavelength / ppw_far``) over ``transition_distance``.
274
+
275
+ Additional per-surface local refinements can be injected via
276
+ ``local_refinements``. Each entry adds its own Distance+Threshold field
277
+ pair and all fields (global + local) are merged with a ``Min`` field so
278
+ the finest size always wins at every point.
279
+
280
+ When *set_as_background* is True (default), the final Min field is
281
+ immediately installed as the background mesh and meshing options are
282
+ configured. Set it to False to compose multiple fields with a ``Min``
283
+ field before calling :func:`set_mesh_field_as_background` yourself.
284
+
285
+ Args:
286
+ surface_dimtags: List of ``(dim, tag)`` pairs for the surfaces whose
287
+ boundary curves drive the global refinement (typically
288
+ PEC and port surfaces).
289
+ wavelength: Operating wavelength in model units.
290
+ ppw_near: Points per wavelength on the conductor boundary curves.
291
+ ppw_far: Points per wavelength far from conductors.
292
+ transition_distance: Distance over which the mesh size grades from fine
293
+ to coarse. Defaults to ``wavelength / 4``.
294
+ set_as_background: If True, install the final field as the background
295
+ mesh and configure meshing options.
296
+ local_refinements: Optional dict mapping ``(dim, tag)`` → ``ppw_local``
297
+ for surfaces that need finer resolution than the
298
+ global ``ppw_near``. Each gets its own
299
+ Distance+Threshold pair with the same
300
+ ``transition_distance`` and ``ppw_far``.
301
+
302
+ Returns:
303
+ The gmsh field ID of the final (Min or Threshold) field that was
304
+ installed as background (or would be, if set_as_background=False).
305
+ """
306
+ if transition_distance is None:
307
+ transition_distance = 0.25 * wavelength
308
+
309
+ lc_near = wavelength / ppw_near
310
+ lc_far = wavelength / ppw_far
311
+
312
+ # ── helper: build one Distance+Threshold pair for a set of dimtags ──
313
+ def _make_threshold(
314
+ dimtags: list[tuple[int, int]],
315
+ size_min: float,
316
+ label: str = "",
317
+ ) -> int:
318
+ edge_set: set[int] = set()
319
+ for dt in dimtags:
320
+ for _, et in gmsh.model.getBoundary(
321
+ [dt], combined=False, oriented=False, recursive=False
322
+ ):
323
+ edge_set.add(abs(et))
324
+ edge_list = sorted(edge_set)
325
+ print(f" {label}: {len(edge_list)} curves, SizeMin={size_min:.4f}")
326
+
327
+ dist = gmsh.model.mesh.field.add("Distance")
328
+ gmsh.model.mesh.field.setNumbers(dist, "CurvesList", edge_list)
329
+ gmsh.model.mesh.field.setNumber(dist, "Sampling", 200)
330
+
331
+ thresh = gmsh.model.mesh.field.add("Threshold")
332
+ gmsh.model.mesh.field.setNumber(thresh, "InField", dist)
333
+ gmsh.model.mesh.field.setNumber(thresh, "SizeMin", size_min)
334
+ gmsh.model.mesh.field.setNumber(thresh, "SizeMax", lc_far)
335
+ gmsh.model.mesh.field.setNumber(thresh, "DistMin", 0.0)
336
+ gmsh.model.mesh.field.setNumber(thresh, "DistMax", transition_distance)
337
+ return thresh
338
+
339
+ # ── 1. Global field ──────────────────────────────────────────────
340
+ print(f" ppw_near={ppw_near} ppw_far={ppw_far}")
341
+ print(f" SizeMax={lc_far:.4f} transition={transition_distance:.4f}")
342
+
343
+ field_ids = [
344
+ _make_threshold(surface_dimtags, lc_near, label="global")
345
+ ]
346
+
347
+ # ── 2. Local override fields ─────────────────────────────────────
348
+ # Each runs AFTER the global field so a finer ppw_local always wins
349
+ # once merged by the Min field below.
350
+ if local_refinements:
351
+ for dimtag, ppw_local in local_refinements.items():
352
+ lc_local = wavelength / ppw_local
353
+ field_ids.append(
354
+ _make_threshold([dimtag], lc_local, label=f"local {dimtag}")
355
+ )
356
+
357
+ # ── 3. Merge all fields with Min ─────────────────────────────────
358
+ # Min field takes the smallest lc at every point in space, so local
359
+ # overrides never conflict — they only ever refine, never coarsen.
360
+ if len(field_ids) > 1:
361
+ min_field = gmsh.model.mesh.field.add("Min")
362
+ gmsh.model.mesh.field.setNumbers(min_field, "FieldsList", field_ids)
363
+ final_field = min_field
364
+ print(f" Merged {len(field_ids)} fields with Min → field {final_field}")
365
+ else:
366
+ final_field = field_ids[0]
367
+
368
+ if set_as_background:
369
+ set_mesh_field_as_background(final_field)
370
+
371
+ return final_field
372
+
373
+
374
+ def set_mesh_field_as_background(field_id: int) -> None:
375
+ """Install a gmsh mesh field as the background mesh and configure options.
376
+
377
+ Disables point / curvature / boundary-extension sizing so that the
378
+ background field has full control over element sizes.
379
+ """
380
+ gmsh.model.mesh.field.setAsBackgroundMesh(field_id)
381
+ gmsh.option.setNumber("Mesh.MeshSizeExtendFromBoundary", 0)
382
+ gmsh.option.setNumber("Mesh.MeshSizeFromPoints", 0)
383
+ gmsh.option.setNumber("Mesh.MeshSizeFromCurvature", 0)
384
+ gmsh.option.setNumber("Mesh.Algorithm", 1) # MeshAdapt
385
+ gmsh.option.setNumber("Mesh.Algorithm3D", 1) # Delaunay
386
+
387
+
388
+ def generate_2d_mesh(
389
+ name_to_surfs: dict[str, list[int]],
390
+ mesh_sizes: dict[str, float],
391
+ output_file: str,
392
+ ) -> None:
393
+ """Set mesh sizes, generate and write a 2D mesh.
394
+
395
+ Args:
396
+ name_to_surfs: mapping from area name → list of surface tags.
397
+ mesh_sizes: mapping from area name → characteristic length.
398
+ output_file: path for the output .msh file.
399
+ """
400
+ # Process regions from coarsest → finest so that fine mesh sizes on shared
401
+ # boundary points always override coarser ones.
402
+ sorted_names = sorted(
403
+ name_to_surfs.keys(),
404
+ key=lambda n: mesh_sizes.get(n, 0.0),
405
+ reverse=True,
406
+ )
407
+ for name in sorted_names:
408
+ stags = name_to_surfs[name]
409
+ lc = mesh_sizes.get(name)
410
+ if lc is None:
411
+ continue
412
+ for stag in stags:
413
+ pts = gmsh.model.getBoundary(
414
+ [(2, stag)], combined=False, oriented=False, recursive=True
415
+ )
416
+ gmsh.model.mesh.setSize([(d, t) for d, t in pts if d == 0], lc)
417
+
418
+ gmsh.model.mesh.generate(2)
419
+ gmsh.option.setNumber("Mesh.MshFileVersion", 2.2)
420
+ gmsh.write(output_file)
421
+ print(f" Cross-section saved to {output_file}")
422
+ print(f" Nodes: {len(gmsh.model.mesh.getNodes()[0])}")
423
+ elems = gmsh.model.mesh.getElements()
424
+ print(f" Elements: {sum(len(e) for e in elems[1])}")
425
+
426
+
427
+ def make_xz_rect(x0: float, x1: float, y: float, z0: float, z1: float) -> int:
428
+ """Create a rectangular surface in the XZ plane at given y coordinate."""
429
+ return make_rect(
430
+ (x0, y, z0), (x1, y, z0), (x1, y, z1), (x0, y, z1),
431
+ )
432
+
433
+
434
+ def make_yz_rect(x: float, y0: float, y1: float, z0: float, z1: float) -> int:
435
+ """Create a rectangular surface in the YZ plane at given x coordinate."""
436
+ return make_rect(
437
+ (x, y0, z0), (x, y1, z0), (x, y1, z1), (x, y0, z1),
438
+ )
439
+
440
+
441
+ def make_rect(
442
+ c0: tuple[float, float, float],
443
+ c1: tuple[float, float, float],
444
+ c2: tuple[float, float, float],
445
+ c3: tuple[float, float, float],
446
+ ) -> int:
447
+ """Create a planar rectangular surface from four corner points.
448
+
449
+ The corners must be given in order (either CW or CCW) so that
450
+ consecutive corners share an edge.
451
+
452
+ Args:
453
+ c0, c1, c2, c3: (x, y, z) coordinates of the four corners.
454
+
455
+ Returns:
456
+ The Gmsh OCC surface tag.
457
+ """
458
+ p1 = gmsh.model.occ.addPoint(*c0)
459
+ p2 = gmsh.model.occ.addPoint(*c1)
460
+ p3 = gmsh.model.occ.addPoint(*c2)
461
+ p4 = gmsh.model.occ.addPoint(*c3)
462
+ loop = gmsh.model.occ.addCurveLoop([
463
+ gmsh.model.occ.addLine(p1, p2),
464
+ gmsh.model.occ.addLine(p2, p3),
465
+ gmsh.model.occ.addLine(p3, p4),
466
+ gmsh.model.occ.addLine(p4, p1),
467
+ ])
468
+ return gmsh.model.occ.addPlaneSurface([loop])