gsim 0.0.2__py3-none-any.whl → 0.0.4__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.
- gsim/__init__.py +1 -1
- gsim/common/__init__.py +9 -13
- gsim/common/stack/extractor.py +4 -4
- gsim/common/stack/materials.py +2 -2
- gsim/common/stack/visualization.py +3 -3
- gsim/gcloud.py +80 -25
- gsim/palace/__init__.py +53 -64
- gsim/palace/base.py +313 -8
- gsim/palace/driven.py +26 -302
- gsim/palace/eigenmode.py +44 -264
- gsim/palace/electrostatic.py +35 -259
- gsim/palace/mesh/__init__.py +13 -1
- gsim/palace/mesh/config_generator.py +367 -0
- gsim/palace/mesh/generator.py +40 -899
- gsim/palace/mesh/geometry.py +472 -0
- gsim/palace/mesh/groups.py +170 -0
- gsim/palace/models/__init__.py +8 -15
- gsim/palace/models/mesh.py +9 -9
- gsim/palace/models/numerical.py +9 -9
- gsim/palace/models/ports.py +4 -5
- gsim/palace/models/problems.py +1 -1
- gsim/palace/models/results.py +5 -4
- gsim/viz.py +9 -6
- {gsim-0.0.2.dist-info → gsim-0.0.4.dist-info}/METADATA +7 -6
- gsim-0.0.4.dist-info/RECORD +35 -0
- {gsim-0.0.2.dist-info → gsim-0.0.4.dist-info}/WHEEL +1 -1
- gsim-0.0.2.dist-info/RECORD +0 -32
- {gsim-0.0.2.dist-info → gsim-0.0.4.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
"""Palace configuration file generation.
|
|
2
|
+
|
|
3
|
+
This module handles generating Palace config.json and collecting mesh statistics.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
import gmsh
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from gsim.common.stack import LayerStack
|
|
16
|
+
from gsim.palace.models import DrivenConfig
|
|
17
|
+
from gsim.palace.ports.config import PalacePort
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def generate_palace_config(
|
|
21
|
+
groups: dict,
|
|
22
|
+
ports: list[PalacePort],
|
|
23
|
+
port_info: list,
|
|
24
|
+
stack: LayerStack,
|
|
25
|
+
output_path: Path,
|
|
26
|
+
model_name: str,
|
|
27
|
+
fmax: float,
|
|
28
|
+
driven_config: DrivenConfig | None = None,
|
|
29
|
+
) -> Path:
|
|
30
|
+
"""Generate Palace config.json file.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
groups: Physical group information from mesh generation
|
|
34
|
+
ports: List of PalacePort objects
|
|
35
|
+
port_info: Port metadata list
|
|
36
|
+
stack: Layer stack for material properties
|
|
37
|
+
output_path: Output directory path
|
|
38
|
+
model_name: Base name for output files
|
|
39
|
+
fmax: Maximum frequency (Hz) - used as fallback if driven_config not provided
|
|
40
|
+
driven_config: Optional DrivenConfig for frequency sweep settings
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
Path to the generated config.json
|
|
44
|
+
"""
|
|
45
|
+
from gsim.palace.ports.config import PortGeometry
|
|
46
|
+
|
|
47
|
+
# Use driven_config if provided, otherwise fall back to legacy parameters
|
|
48
|
+
if driven_config is not None:
|
|
49
|
+
solver_driven = driven_config.to_palace_config()
|
|
50
|
+
else:
|
|
51
|
+
# Legacy behavior - compute from fmax
|
|
52
|
+
freq_step = fmax / 40e9
|
|
53
|
+
solver_driven = {
|
|
54
|
+
"Samples": [
|
|
55
|
+
{
|
|
56
|
+
"Type": "Linear",
|
|
57
|
+
"MinFreq": 1.0, # 1 GHz
|
|
58
|
+
"MaxFreq": fmax / 1e9,
|
|
59
|
+
"FreqStep": freq_step,
|
|
60
|
+
"SaveStep": 0,
|
|
61
|
+
}
|
|
62
|
+
],
|
|
63
|
+
"AdaptiveTol": 0.02,
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
config: dict[str, object] = {
|
|
67
|
+
"Problem": {
|
|
68
|
+
"Type": "Driven",
|
|
69
|
+
"Verbose": 3,
|
|
70
|
+
"Output": f"output/{model_name}",
|
|
71
|
+
},
|
|
72
|
+
"Model": {
|
|
73
|
+
"Mesh": f"{model_name}.msh",
|
|
74
|
+
"L0": 1e-6, # um
|
|
75
|
+
"Refinement": {
|
|
76
|
+
"UniformLevels": 0,
|
|
77
|
+
"Tol": 1e-2,
|
|
78
|
+
"MaxIts": 0,
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
"Solver": {
|
|
82
|
+
"Linear": {
|
|
83
|
+
"Type": "Default",
|
|
84
|
+
"KSPType": "GMRES",
|
|
85
|
+
"Tol": 1e-6,
|
|
86
|
+
"MaxIts": 400,
|
|
87
|
+
},
|
|
88
|
+
"Order": 2,
|
|
89
|
+
"Device": "CPU",
|
|
90
|
+
"Driven": solver_driven,
|
|
91
|
+
},
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
# Build domains section
|
|
95
|
+
materials: list[dict[str, object]] = []
|
|
96
|
+
for material_name, info in groups["volumes"].items():
|
|
97
|
+
mat_props = stack.materials.get(material_name, {})
|
|
98
|
+
mat_entry: dict[str, object] = {"Attributes": [info["phys_group"]]}
|
|
99
|
+
|
|
100
|
+
if material_name == "airbox":
|
|
101
|
+
mat_entry["Permittivity"] = 1.0
|
|
102
|
+
mat_entry["LossTan"] = 0.0
|
|
103
|
+
else:
|
|
104
|
+
mat_entry["Permittivity"] = mat_props.get("permittivity", 1.0)
|
|
105
|
+
sigma = mat_props.get("conductivity", 0.0)
|
|
106
|
+
if sigma > 0:
|
|
107
|
+
mat_entry["Conductivity"] = sigma
|
|
108
|
+
else:
|
|
109
|
+
mat_entry["LossTan"] = mat_props.get("loss_tangent", 0.0)
|
|
110
|
+
|
|
111
|
+
materials.append(mat_entry)
|
|
112
|
+
|
|
113
|
+
config["Domains"] = {
|
|
114
|
+
"Materials": materials,
|
|
115
|
+
"Postprocessing": {"Energy": [], "Probe": []},
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
# Build boundaries section
|
|
119
|
+
conductors: list[dict[str, object]] = []
|
|
120
|
+
for name, info in groups["conductor_surfaces"].items():
|
|
121
|
+
# Extract layer name from "layer_xy" or "layer_z"
|
|
122
|
+
layer_name = name.rsplit("_", 1)[0]
|
|
123
|
+
layer = stack.layers.get(layer_name)
|
|
124
|
+
if layer:
|
|
125
|
+
mat_props = stack.materials.get(layer.material, {})
|
|
126
|
+
conductors.append(
|
|
127
|
+
{
|
|
128
|
+
"Attributes": [info["phys_group"]],
|
|
129
|
+
"Conductivity": mat_props.get("conductivity", 5.8e7),
|
|
130
|
+
"Thickness": layer.zmax - layer.zmin,
|
|
131
|
+
}
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
lumped_ports: list[dict[str, object]] = []
|
|
135
|
+
port_idx = 1
|
|
136
|
+
|
|
137
|
+
for port in ports:
|
|
138
|
+
port_key = f"P{port_idx}"
|
|
139
|
+
if port_key in groups["port_surfaces"]:
|
|
140
|
+
port_group = groups["port_surfaces"][port_key]
|
|
141
|
+
|
|
142
|
+
if port.multi_element:
|
|
143
|
+
# Multi-element port (CPW)
|
|
144
|
+
if port_group.get("type") == "cpw":
|
|
145
|
+
elements = [
|
|
146
|
+
{
|
|
147
|
+
"Attributes": [elem["phys_group"]],
|
|
148
|
+
"Direction": elem["direction"],
|
|
149
|
+
}
|
|
150
|
+
for elem in port_group["elements"]
|
|
151
|
+
]
|
|
152
|
+
|
|
153
|
+
lumped_ports.append(
|
|
154
|
+
{
|
|
155
|
+
"Index": port_idx,
|
|
156
|
+
"R": port.impedance,
|
|
157
|
+
"Excitation": port_idx if port.excited else False,
|
|
158
|
+
"Elements": elements,
|
|
159
|
+
}
|
|
160
|
+
)
|
|
161
|
+
else:
|
|
162
|
+
# Single-element port
|
|
163
|
+
direction = (
|
|
164
|
+
"Z" if port.geometry == PortGeometry.VIA else port.direction.upper()
|
|
165
|
+
)
|
|
166
|
+
lumped_ports.append(
|
|
167
|
+
{
|
|
168
|
+
"Index": port_idx,
|
|
169
|
+
"R": port.impedance,
|
|
170
|
+
"Direction": direction,
|
|
171
|
+
"Excitation": port_idx if port.excited else False,
|
|
172
|
+
"Attributes": [port_group["phys_group"]],
|
|
173
|
+
}
|
|
174
|
+
)
|
|
175
|
+
port_idx += 1
|
|
176
|
+
|
|
177
|
+
boundaries: dict[str, object] = {
|
|
178
|
+
"Conductivity": conductors,
|
|
179
|
+
"LumpedPort": lumped_ports,
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if "absorbing" in groups["boundary_surfaces"]:
|
|
183
|
+
boundaries["Absorbing"] = {
|
|
184
|
+
"Attributes": [groups["boundary_surfaces"]["absorbing"]["phys_group"]],
|
|
185
|
+
"Order": 2,
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
config["Boundaries"] = boundaries
|
|
189
|
+
|
|
190
|
+
# Write config file
|
|
191
|
+
config_path = output_path / "config.json"
|
|
192
|
+
with config_path.open("w") as f:
|
|
193
|
+
json.dump(config, f, indent=4)
|
|
194
|
+
|
|
195
|
+
# Write port information file
|
|
196
|
+
port_info_path = output_path / "port_information.json"
|
|
197
|
+
port_info_struct = {"ports": port_info, "unit": 1e-6, "name": model_name}
|
|
198
|
+
with port_info_path.open("w") as f:
|
|
199
|
+
json.dump(port_info_struct, f, indent=4)
|
|
200
|
+
|
|
201
|
+
return config_path
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def collect_mesh_stats() -> dict:
|
|
205
|
+
"""Collect mesh statistics from gmsh after mesh generation.
|
|
206
|
+
|
|
207
|
+
Must be called while gmsh is initialized and the mesh is generated.
|
|
208
|
+
|
|
209
|
+
Returns:
|
|
210
|
+
Dict with mesh statistics including:
|
|
211
|
+
- bbox: Bounding box coordinates
|
|
212
|
+
- nodes: Number of nodes
|
|
213
|
+
- elements: Total element count
|
|
214
|
+
- tetrahedra: Tet count
|
|
215
|
+
- quality: Shape quality metrics (gamma)
|
|
216
|
+
- sicn: Signed Inverse Condition Number
|
|
217
|
+
- edge_length: Min/max edge lengths
|
|
218
|
+
- groups: Physical group info
|
|
219
|
+
"""
|
|
220
|
+
stats = {}
|
|
221
|
+
|
|
222
|
+
# Get bounding box
|
|
223
|
+
try:
|
|
224
|
+
xmin, ymin, zmin, xmax, ymax, zmax = gmsh.model.getBoundingBox(-1, -1)
|
|
225
|
+
stats["bbox"] = {
|
|
226
|
+
"xmin": xmin,
|
|
227
|
+
"ymin": ymin,
|
|
228
|
+
"zmin": zmin,
|
|
229
|
+
"xmax": xmax,
|
|
230
|
+
"ymax": ymax,
|
|
231
|
+
"zmax": zmax,
|
|
232
|
+
}
|
|
233
|
+
except Exception:
|
|
234
|
+
pass
|
|
235
|
+
|
|
236
|
+
# Get node count
|
|
237
|
+
try:
|
|
238
|
+
node_tags, _, _ = gmsh.model.mesh.getNodes()
|
|
239
|
+
stats["nodes"] = len(node_tags)
|
|
240
|
+
except Exception:
|
|
241
|
+
pass
|
|
242
|
+
|
|
243
|
+
# Get element counts and collect tet tags for quality
|
|
244
|
+
tet_tags = []
|
|
245
|
+
try:
|
|
246
|
+
element_types, element_tags, _ = gmsh.model.mesh.getElements()
|
|
247
|
+
total_elements = sum(len(tags) for tags in element_tags)
|
|
248
|
+
stats["elements"] = total_elements
|
|
249
|
+
|
|
250
|
+
# Count tetrahedra (type 4) and save tags
|
|
251
|
+
for etype, tags in zip(element_types, element_tags, strict=False):
|
|
252
|
+
if etype == 4: # 4-node tetrahedron
|
|
253
|
+
stats["tetrahedra"] = len(tags)
|
|
254
|
+
tet_tags = list(tags)
|
|
255
|
+
except Exception:
|
|
256
|
+
pass
|
|
257
|
+
|
|
258
|
+
# Get mesh quality for tetrahedra
|
|
259
|
+
if tet_tags:
|
|
260
|
+
# Gamma: inscribed/circumscribed radius ratio (shape quality)
|
|
261
|
+
try:
|
|
262
|
+
qualities = gmsh.model.mesh.getElementQualities(tet_tags, "gamma")
|
|
263
|
+
if len(qualities) > 0:
|
|
264
|
+
stats["quality"] = {
|
|
265
|
+
"min": round(min(qualities), 3),
|
|
266
|
+
"max": round(max(qualities), 3),
|
|
267
|
+
"mean": round(sum(qualities) / len(qualities), 3),
|
|
268
|
+
}
|
|
269
|
+
except Exception:
|
|
270
|
+
pass
|
|
271
|
+
|
|
272
|
+
# SICN: Signed Inverse Condition Number (negative = invalid element)
|
|
273
|
+
try:
|
|
274
|
+
sicn = gmsh.model.mesh.getElementQualities(tet_tags, "minSICN")
|
|
275
|
+
if len(sicn) > 0:
|
|
276
|
+
sicn_min = min(sicn)
|
|
277
|
+
invalid_count = sum(1 for s in sicn if s < 0)
|
|
278
|
+
stats["sicn"] = {
|
|
279
|
+
"min": round(sicn_min, 3),
|
|
280
|
+
"mean": round(sum(sicn) / len(sicn), 3),
|
|
281
|
+
"invalid": invalid_count,
|
|
282
|
+
}
|
|
283
|
+
except Exception:
|
|
284
|
+
pass
|
|
285
|
+
|
|
286
|
+
# Edge lengths
|
|
287
|
+
try:
|
|
288
|
+
min_edges = gmsh.model.mesh.getElementQualities(tet_tags, "minEdge")
|
|
289
|
+
max_edges = gmsh.model.mesh.getElementQualities(tet_tags, "maxEdge")
|
|
290
|
+
if len(min_edges) > 0 and len(max_edges) > 0:
|
|
291
|
+
stats["edge_length"] = {
|
|
292
|
+
"min": round(min(min_edges), 3),
|
|
293
|
+
"max": round(max(max_edges), 3),
|
|
294
|
+
}
|
|
295
|
+
except Exception:
|
|
296
|
+
pass
|
|
297
|
+
|
|
298
|
+
# Get physical groups with tags
|
|
299
|
+
try:
|
|
300
|
+
groups = {"volumes": [], "surfaces": []}
|
|
301
|
+
for dim, tag in gmsh.model.getPhysicalGroups():
|
|
302
|
+
name = gmsh.model.getPhysicalName(dim, tag)
|
|
303
|
+
entry = {"name": name, "tag": tag}
|
|
304
|
+
if dim == 3:
|
|
305
|
+
groups["volumes"].append(entry)
|
|
306
|
+
elif dim == 2:
|
|
307
|
+
groups["surfaces"].append(entry)
|
|
308
|
+
stats["groups"] = groups
|
|
309
|
+
except Exception:
|
|
310
|
+
pass
|
|
311
|
+
|
|
312
|
+
return stats
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def write_config(
|
|
316
|
+
mesh_result,
|
|
317
|
+
stack: LayerStack,
|
|
318
|
+
ports: list[PalacePort],
|
|
319
|
+
driven_config: DrivenConfig | None = None,
|
|
320
|
+
) -> Path:
|
|
321
|
+
"""Write Palace config.json from a MeshResult.
|
|
322
|
+
|
|
323
|
+
Use this to generate config separately after mesh().
|
|
324
|
+
|
|
325
|
+
Args:
|
|
326
|
+
mesh_result: Result from generate_mesh(write_config=False)
|
|
327
|
+
stack: LayerStack for material properties
|
|
328
|
+
ports: List of PalacePort objects
|
|
329
|
+
driven_config: Optional DrivenConfig for frequency sweep settings
|
|
330
|
+
|
|
331
|
+
Returns:
|
|
332
|
+
Path to the generated config.json
|
|
333
|
+
|
|
334
|
+
Raises:
|
|
335
|
+
ValueError: If mesh_result has no groups data
|
|
336
|
+
|
|
337
|
+
Example:
|
|
338
|
+
>>> result = sim.mesh(output_dir, write_config=False)
|
|
339
|
+
>>> config_path = write_config(result, stack, ports, driven_config)
|
|
340
|
+
"""
|
|
341
|
+
if not mesh_result.groups:
|
|
342
|
+
raise ValueError(
|
|
343
|
+
"MeshResult has no groups data. Was it generated with write_config=False?"
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
config_path = generate_palace_config(
|
|
347
|
+
groups=mesh_result.groups,
|
|
348
|
+
ports=ports,
|
|
349
|
+
port_info=mesh_result.port_info,
|
|
350
|
+
stack=stack,
|
|
351
|
+
output_path=mesh_result.output_dir,
|
|
352
|
+
model_name=mesh_result.model_name,
|
|
353
|
+
fmax=mesh_result.fmax,
|
|
354
|
+
driven_config=driven_config,
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
# Update the mesh_result with the config path
|
|
358
|
+
mesh_result.config_path = config_path
|
|
359
|
+
|
|
360
|
+
return config_path
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
__all__ = [
|
|
364
|
+
"collect_mesh_stats",
|
|
365
|
+
"generate_palace_config",
|
|
366
|
+
"write_config",
|
|
367
|
+
]
|