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.
@@ -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
+ ]