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.
- palace_toolkit-0.1.0.dist-info/METADATA +29 -0
- palace_toolkit-0.1.0.dist-info/RECORD +16 -0
- palace_toolkit-0.1.0.dist-info/WHEEL +5 -0
- palace_toolkit-0.1.0.dist-info/top_level.txt +1 -0
- palacetoolkit/__init__.py +31 -0
- palacetoolkit/analytic.py +60 -0
- palacetoolkit/geometry.py +48 -0
- palacetoolkit/mesh.py +468 -0
- palacetoolkit/mode_solver.py +955 -0
- palacetoolkit/palace_runtime.py +37 -0
- palacetoolkit/plot_farfield.py +292 -0
- palacetoolkit/s_plot.py +29 -0
- palacetoolkit/simulation.py +508 -0
- palacetoolkit/utils.py +460 -0
- palacetoolkit/verify_topology.py +793 -0
- palacetoolkit/viz.py +999 -0
|
@@ -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 @@
|
|
|
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])
|