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.
@@ -6,7 +6,6 @@ replacing the gds2palace backend with a cleaner implementation.
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
- import json
10
9
  import logging
11
10
  import math
12
11
  from dataclasses import dataclass, field
@@ -16,26 +15,28 @@ from typing import TYPE_CHECKING
16
15
  import gmsh
17
16
 
18
17
  from . import gmsh_utils
18
+ from .config_generator import (
19
+ collect_mesh_stats,
20
+ generate_palace_config,
21
+ write_config,
22
+ )
23
+ from .geometry import (
24
+ GeometryData,
25
+ add_dielectrics,
26
+ add_metals,
27
+ add_ports,
28
+ extract_geometry,
29
+ )
30
+ from .groups import assign_physical_groups
19
31
 
20
32
  if TYPE_CHECKING:
33
+ from gsim.common.stack import LayerStack
21
34
  from gsim.palace.models import DrivenConfig
22
35
  from gsim.palace.ports.config import PalacePort
23
- from gsim.common.stack import LayerStack
24
-
25
- from gsim.palace.ports.config import PortGeometry
26
36
 
27
37
  logger = logging.getLogger(__name__)
28
38
 
29
39
 
30
- @dataclass
31
- class GeometryData:
32
- """Container for geometry data extracted from component."""
33
-
34
- polygons: list # List of (layer_num, pts_x, pts_y) tuples
35
- bbox: tuple[float, float, float, float] # (xmin, ymin, xmax, ymax)
36
- layer_bboxes: dict # layer_num -> (xmin, ymin, xmax, ymax)
37
-
38
-
39
40
  @dataclass
40
41
  class MeshResult:
41
42
  """Result from mesh generation."""
@@ -51,567 +52,6 @@ class MeshResult:
51
52
  fmax: float = 100e9
52
53
 
53
54
 
54
- def extract_geometry(component, stack: LayerStack) -> GeometryData:
55
- """Extract polygon geometry from a gdsfactory component.
56
-
57
- Args:
58
- component: gdsfactory Component
59
- stack: LayerStack for layer mapping
60
-
61
- Returns:
62
- GeometryData with polygons and bounding boxes
63
- """
64
- polygons = []
65
- global_bbox = [math.inf, math.inf, -math.inf, -math.inf]
66
- layer_bboxes = {}
67
-
68
- # Get polygons from component
69
- polygons_by_index = component.get_polygons()
70
-
71
- # Build layer_index -> GDS tuple mapping
72
- layout = component.kcl.layout
73
- index_to_gds = {}
74
- for layer_index in range(layout.layers()):
75
- if layout.is_valid_layer(layer_index):
76
- info = layout.get_info(layer_index)
77
- index_to_gds[layer_index] = (info.layer, info.datatype)
78
-
79
- # Build GDS tuple -> layer number mapping
80
- gds_to_layernum = {}
81
- for layer_data in stack.layers.values():
82
- gds_tuple = tuple(layer_data.gds_layer)
83
- gds_to_layernum[gds_tuple] = gds_tuple[0]
84
-
85
- # Convert polygons
86
- for layer_index, polys in polygons_by_index.items():
87
- gds_tuple = index_to_gds.get(layer_index)
88
- if gds_tuple is None:
89
- continue
90
-
91
- layernum = gds_to_layernum.get(gds_tuple)
92
- if layernum is None:
93
- continue
94
-
95
- for poly in polys:
96
- # Convert klayout polygon to lists (nm -> um)
97
- points = list(poly.each_point_hull())
98
- if len(points) < 3:
99
- continue
100
-
101
- pts_x = [pt.x / 1000.0 for pt in points]
102
- pts_y = [pt.y / 1000.0 for pt in points]
103
-
104
- polygons.append((layernum, pts_x, pts_y))
105
-
106
- # Update bounding boxes
107
- xmin, xmax = min(pts_x), max(pts_x)
108
- ymin, ymax = min(pts_y), max(pts_y)
109
-
110
- global_bbox[0] = min(global_bbox[0], xmin)
111
- global_bbox[1] = min(global_bbox[1], ymin)
112
- global_bbox[2] = max(global_bbox[2], xmax)
113
- global_bbox[3] = max(global_bbox[3], ymax)
114
-
115
- if layernum not in layer_bboxes:
116
- layer_bboxes[layernum] = [xmin, ymin, xmax, ymax]
117
- else:
118
- bbox = layer_bboxes[layernum]
119
- bbox[0] = min(bbox[0], xmin)
120
- bbox[1] = min(bbox[1], ymin)
121
- bbox[2] = max(bbox[2], xmax)
122
- bbox[3] = max(bbox[3], ymax)
123
-
124
- return GeometryData(
125
- polygons=polygons,
126
- bbox=(global_bbox[0], global_bbox[1], global_bbox[2], global_bbox[3]),
127
- layer_bboxes=layer_bboxes,
128
- )
129
-
130
-
131
- def _get_layer_info(stack: LayerStack, gds_layer: int) -> dict | None:
132
- """Get layer info from stack by GDS layer number."""
133
- for name, layer in stack.layers.items():
134
- if layer.gds_layer[0] == gds_layer:
135
- return {
136
- "name": name,
137
- "zmin": layer.zmin,
138
- "zmax": layer.zmax,
139
- "thickness": layer.zmax - layer.zmin,
140
- "material": layer.material,
141
- "type": layer.layer_type,
142
- }
143
- return None
144
-
145
-
146
- def _add_metals(
147
- kernel,
148
- geometry: GeometryData,
149
- stack: LayerStack,
150
- ) -> dict:
151
- """Add metal and via geometries to gmsh.
152
-
153
- Creates extruded volumes for vias and shells (surfaces) for conductors.
154
-
155
- Returns:
156
- Dict with layer_name -> list of (surface_tags_xy, surface_tags_z) for
157
- conductors, or volume_tags for vias.
158
- """
159
- # layer_name -> {"volumes": [], "surfaces_xy": [], "surfaces_z": []}
160
- metal_tags = {}
161
-
162
- # Group polygons by layer
163
- polygons_by_layer = {}
164
- for layernum, pts_x, pts_y in geometry.polygons:
165
- if layernum not in polygons_by_layer:
166
- polygons_by_layer[layernum] = []
167
- polygons_by_layer[layernum].append((pts_x, pts_y))
168
-
169
- # Process each layer
170
- for layernum, polys in polygons_by_layer.items():
171
- layer_info = _get_layer_info(stack, layernum)
172
- if layer_info is None:
173
- continue
174
-
175
- layer_name = layer_info["name"]
176
- layer_type = layer_info["type"]
177
- zmin = layer_info["zmin"]
178
- thickness = layer_info["thickness"]
179
-
180
- if layer_type not in ("conductor", "via"):
181
- continue
182
-
183
- if layer_name not in metal_tags:
184
- metal_tags[layer_name] = {
185
- "volumes": [],
186
- "surfaces_xy": [],
187
- "surfaces_z": [],
188
- }
189
-
190
- for pts_x, pts_y in polys:
191
- # Create extruded polygon
192
- surfacetag = gmsh_utils.create_polygon_surface(kernel, pts_x, pts_y, zmin)
193
- if surfacetag is None:
194
- continue
195
-
196
- if thickness > 0:
197
- result = kernel.extrude([(2, surfacetag)], 0, 0, thickness)
198
- volumetag = result[1][1]
199
-
200
- if layer_type == "via":
201
- # Keep vias as volumes
202
- metal_tags[layer_name]["volumes"].append(volumetag)
203
- else:
204
- # For conductors, get shell surfaces and remove volume
205
- _, surfaceloops = kernel.getSurfaceLoops(volumetag)
206
- if surfaceloops:
207
- metal_tags[layer_name]["volumes"].append(
208
- (volumetag, surfaceloops[0])
209
- )
210
- kernel.remove([(3, volumetag)])
211
-
212
- kernel.removeAllDuplicates()
213
- kernel.synchronize()
214
-
215
- return metal_tags
216
-
217
-
218
- def _add_dielectrics(
219
- kernel,
220
- geometry: GeometryData,
221
- stack: LayerStack,
222
- margin: float,
223
- air_margin: float,
224
- ) -> dict:
225
- """Add dielectric boxes and airbox to gmsh.
226
-
227
- Returns:
228
- Dict with material_name -> list of volume_tags
229
- """
230
- dielectric_tags = {}
231
-
232
- # Get overall geometry bounds
233
- xmin, ymin, xmax, ymax = geometry.bbox
234
- xmin -= margin
235
- ymin -= margin
236
- xmax += margin
237
- ymax += margin
238
-
239
- # Track overall z range
240
- z_min_all = math.inf
241
- z_max_all = -math.inf
242
-
243
- # Sort dielectrics by z (top to bottom for correct layering)
244
- sorted_dielectrics = sorted(
245
- stack.dielectrics, key=lambda d: d["zmax"], reverse=True
246
- )
247
-
248
- # Add dielectric boxes
249
- offset = 0
250
- offset_delta = margin / 20
251
-
252
- for dielectric in sorted_dielectrics:
253
- material = dielectric["material"]
254
- d_zmin = dielectric["zmin"]
255
- d_zmax = dielectric["zmax"]
256
-
257
- z_min_all = min(z_min_all, d_zmin)
258
- z_max_all = max(z_max_all, d_zmax)
259
-
260
- if material not in dielectric_tags:
261
- dielectric_tags[material] = []
262
-
263
- # Create box with slight offset to avoid mesh issues
264
- box_tag = gmsh_utils.create_box(
265
- kernel,
266
- xmin - offset,
267
- ymin - offset,
268
- d_zmin,
269
- xmax + offset,
270
- ymax + offset,
271
- d_zmax,
272
- )
273
- dielectric_tags[material].append(box_tag)
274
-
275
- # Alternate offset to avoid coincident faces
276
- offset = offset_delta if offset == 0 else 0
277
-
278
- # Add surrounding airbox
279
- air_xmin = xmin - air_margin
280
- air_ymin = ymin - air_margin
281
- air_xmax = xmax + air_margin
282
- air_ymax = ymax + air_margin
283
- air_zmin = z_min_all - air_margin
284
- air_zmax = z_max_all + air_margin
285
-
286
- airbox_tag = kernel.addBox(
287
- air_xmin,
288
- air_ymin,
289
- air_zmin,
290
- air_xmax - air_xmin,
291
- air_ymax - air_ymin,
292
- air_zmax - air_zmin,
293
- )
294
- dielectric_tags["airbox"] = [airbox_tag]
295
-
296
- kernel.synchronize()
297
-
298
- return dielectric_tags
299
-
300
-
301
- def _add_ports(
302
- kernel,
303
- ports: list[PalacePort],
304
- stack: LayerStack,
305
- ) -> tuple[dict, list]:
306
- """Add port surfaces to gmsh.
307
-
308
- Args:
309
- kernel: gmsh kernel
310
- ports: List of PalacePort objects (single or multi-element)
311
- stack: Layer stack
312
-
313
- Returns:
314
- (port_tags dict, port_info list)
315
-
316
- For single-element ports: port_tags["P{num}"] = [surface_tag]
317
- For multi-element ports: port_tags["P{num}"] = [surface_tag, surface_tag, ...]
318
- """
319
- port_tags = {} # "P{num}" -> [surface_tag(s)]
320
- port_info = []
321
- port_num = 1
322
-
323
- for port in ports:
324
- if port.multi_element:
325
- # Multi-element port (CPW)
326
- if port.layer is None or port.centers is None or port.directions is None:
327
- continue
328
- target_layer = stack.layers.get(port.layer)
329
- if target_layer is None:
330
- continue
331
-
332
- z = target_layer.zmin
333
- hw = port.width / 2
334
- hl = (port.length or port.width) / 2
335
-
336
- # Determine axis from orientation
337
- angle = port.orientation % 360
338
- is_y_axis = 45 <= angle < 135 or 225 <= angle < 315
339
-
340
- surfaces = []
341
- for cx, cy in port.centers:
342
- if is_y_axis:
343
- surf = gmsh_utils.create_port_rectangle(
344
- kernel, cx - hw, cy - hl, z, cx + hw, cy + hl, z
345
- )
346
- else:
347
- surf = gmsh_utils.create_port_rectangle(
348
- kernel, cx - hl, cy - hw, z, cx + hl, cy + hw, z
349
- )
350
- surfaces.append(surf)
351
-
352
- port_tags[f"P{port_num}"] = surfaces
353
-
354
- port_info.append(
355
- {
356
- "portnumber": port_num,
357
- "Z0": port.impedance,
358
- "type": "cpw",
359
- "elements": [
360
- {"surface_idx": i, "direction": port.directions[i]}
361
- for i in range(len(port.centers))
362
- ],
363
- "width": port.width,
364
- "length": port.length or port.width,
365
- "zmin": z,
366
- "zmax": z,
367
- }
368
- )
369
-
370
- elif port.geometry == PortGeometry.VIA:
371
- # Via port: vertical between two layers
372
- if port.from_layer is None or port.to_layer is None:
373
- continue
374
- from_layer = stack.layers.get(port.from_layer)
375
- to_layer = stack.layers.get(port.to_layer)
376
- if from_layer is None or to_layer is None:
377
- continue
378
-
379
- x, y = port.center
380
- hw = port.width / 2
381
-
382
- if from_layer.zmin < to_layer.zmin:
383
- zmin = from_layer.zmax
384
- zmax = to_layer.zmin
385
- else:
386
- zmin = to_layer.zmax
387
- zmax = from_layer.zmin
388
-
389
- # Create vertical port surface
390
- if port.direction in ("x", "-x"):
391
- surfacetag = gmsh_utils.create_port_rectangle(
392
- kernel, x, y - hw, zmin, x, y + hw, zmax
393
- )
394
- else:
395
- surfacetag = gmsh_utils.create_port_rectangle(
396
- kernel, x - hw, y, zmin, x + hw, y, zmax
397
- )
398
-
399
- port_tags[f"P{port_num}"] = [surfacetag]
400
- port_info.append(
401
- {
402
- "portnumber": port_num,
403
- "Z0": port.impedance,
404
- "type": "via",
405
- "direction": "Z",
406
- "length": zmax - zmin,
407
- "width": port.width,
408
- "xmin": x - hw if port.direction in ("y", "-y") else x,
409
- "xmax": x + hw if port.direction in ("y", "-y") else x,
410
- "ymin": y - hw if port.direction in ("x", "-x") else y,
411
- "ymax": y + hw if port.direction in ("x", "-x") else y,
412
- "zmin": zmin,
413
- "zmax": zmax,
414
- }
415
- )
416
-
417
- else:
418
- # Inplane port: horizontal on single layer
419
- if port.layer is None:
420
- continue
421
- target_layer = stack.layers.get(port.layer)
422
- if target_layer is None:
423
- continue
424
-
425
- x, y = port.center
426
- hw = port.width / 2
427
- z = target_layer.zmin
428
-
429
- hl = (port.length or port.width) / 2
430
- if port.direction in ("x", "-x"):
431
- surfacetag = gmsh_utils.create_port_rectangle(
432
- kernel, x - hl, y - hw, z, x + hl, y + hw, z
433
- )
434
- length = 2 * hl
435
- width = port.width
436
- else:
437
- surfacetag = gmsh_utils.create_port_rectangle(
438
- kernel, x - hw, y - hl, z, x + hw, y + hl, z
439
- )
440
- length = port.width
441
- width = 2 * hl
442
-
443
- port_tags[f"P{port_num}"] = [surfacetag]
444
- port_info.append(
445
- {
446
- "portnumber": port_num,
447
- "Z0": port.impedance,
448
- "type": "lumped",
449
- "direction": port.direction.upper(),
450
- "length": length,
451
- "width": width,
452
- "xmin": x - hl if port.direction in ("x", "-x") else x - hw,
453
- "xmax": x + hl if port.direction in ("x", "-x") else x + hw,
454
- "ymin": y - hw if port.direction in ("x", "-x") else y - hl,
455
- "ymax": y + hw if port.direction in ("x", "-x") else y + hl,
456
- "zmin": z,
457
- "zmax": z,
458
- }
459
- )
460
-
461
- port_num += 1
462
-
463
- kernel.synchronize()
464
-
465
- return port_tags, port_info
466
-
467
-
468
- def _assign_physical_groups(
469
- kernel,
470
- metal_tags: dict,
471
- dielectric_tags: dict,
472
- port_tags: dict,
473
- port_info: list,
474
- geom_dimtags: list,
475
- geom_map: list,
476
- _stack: LayerStack,
477
- ) -> dict:
478
- """Assign physical groups after fragmenting.
479
-
480
- Args:
481
- kernel: gmsh kernel
482
- metal_tags: Metal layer tags
483
- dielectric_tags: Dielectric material tags
484
- port_tags: Port surface tags (may have multiple surfaces for CPW)
485
- port_info: Port metadata including type info
486
- geom_dimtags: Dimension tags from fragmentation
487
- geom_map: Geometry map from fragmentation
488
- _stack: Layer stack (unused; reserved for future material metadata)
489
-
490
- Returns:
491
- Dict with group info for config file generation
492
- """
493
- groups = {
494
- "volumes": {},
495
- "conductor_surfaces": {},
496
- "port_surfaces": {},
497
- "boundary_surfaces": {},
498
- }
499
-
500
- # Assign volume groups for dielectrics
501
- for material_name, tags in dielectric_tags.items():
502
- new_tags = gmsh_utils.get_tags_after_fragment(
503
- tags, geom_dimtags, geom_map, dimension=3
504
- )
505
- if new_tags:
506
- # Only take first N tags (same as original count)
507
- new_tags = new_tags[: len(tags)]
508
- phys_group = gmsh_utils.assign_physical_group(3, new_tags, material_name)
509
- groups["volumes"][material_name] = {
510
- "phys_group": phys_group,
511
- "tags": new_tags,
512
- }
513
-
514
- # Assign surface groups for conductors
515
- for layer_name, tag_info in metal_tags.items():
516
- if tag_info["volumes"]:
517
- all_xy_tags = []
518
- all_z_tags = []
519
-
520
- for item in tag_info["volumes"]:
521
- if isinstance(item, tuple):
522
- _volumetag, surface_tags = item
523
- # Get updated surface tags after fragment
524
- new_surface_tags = gmsh_utils.get_tags_after_fragment(
525
- surface_tags, geom_dimtags, geom_map, dimension=2
526
- )
527
-
528
- # Separate xy and z surfaces
529
- for tag in new_surface_tags:
530
- if gmsh_utils.is_vertical_surface(tag):
531
- all_z_tags.append(tag)
532
- else:
533
- all_xy_tags.append(tag)
534
-
535
- if all_xy_tags:
536
- phys_group = gmsh_utils.assign_physical_group(
537
- 2, all_xy_tags, f"{layer_name}_xy"
538
- )
539
- groups["conductor_surfaces"][f"{layer_name}_xy"] = {
540
- "phys_group": phys_group,
541
- "tags": all_xy_tags,
542
- }
543
-
544
- if all_z_tags:
545
- phys_group = gmsh_utils.assign_physical_group(
546
- 2, all_z_tags, f"{layer_name}_z"
547
- )
548
- groups["conductor_surfaces"][f"{layer_name}_z"] = {
549
- "phys_group": phys_group,
550
- "tags": all_z_tags,
551
- }
552
-
553
- # Assign port surface groups
554
- for port_name, tags in port_tags.items():
555
- # Find corresponding port_info entry
556
- port_num = int(port_name[1:]) # "P1" -> 1
557
- info = next((p for p in port_info if p["portnumber"] == port_num), None)
558
-
559
- if info and info.get("type") == "cpw":
560
- # CPW port: create separate physical group for each element
561
- element_phys_groups = []
562
- for i, tag in enumerate(tags):
563
- new_tag_list = gmsh_utils.get_tags_after_fragment(
564
- [tag], geom_dimtags, geom_map, dimension=2
565
- )
566
- if new_tag_list:
567
- elem_name = f"{port_name}_E{i}"
568
- phys_group = gmsh_utils.assign_physical_group(
569
- 2, new_tag_list, elem_name
570
- )
571
- element_phys_groups.append(
572
- {
573
- "phys_group": phys_group,
574
- "tags": new_tag_list,
575
- "direction": info["elements"][i]["direction"],
576
- }
577
- )
578
-
579
- groups["port_surfaces"][port_name] = {
580
- "type": "cpw",
581
- "elements": element_phys_groups,
582
- }
583
- else:
584
- # Regular single-element port
585
- new_tags = gmsh_utils.get_tags_after_fragment(
586
- tags, geom_dimtags, geom_map, dimension=2
587
- )
588
- if new_tags:
589
- phys_group = gmsh_utils.assign_physical_group(2, new_tags, port_name)
590
- groups["port_surfaces"][port_name] = {
591
- "phys_group": phys_group,
592
- "tags": new_tags,
593
- }
594
-
595
- # Assign boundary surfaces (from airbox)
596
- if "airbox" in groups["volumes"]:
597
- airbox_tags = groups["volumes"]["airbox"]["tags"]
598
- if airbox_tags:
599
- _, simulation_boundary = kernel.getSurfaceLoops(airbox_tags[0])
600
- if simulation_boundary:
601
- boundary_tags = list(next(iter(simulation_boundary)))
602
- phys_group = gmsh_utils.assign_physical_group(
603
- 2, boundary_tags, "Absorbing_boundary"
604
- )
605
- groups["boundary_surfaces"]["absorbing"] = {
606
- "phys_group": phys_group,
607
- "tags": boundary_tags,
608
- }
609
-
610
- kernel.synchronize()
611
-
612
- return groups
613
-
614
-
615
55
  def _setup_mesh_fields(
616
56
  kernel,
617
57
  groups: dict,
@@ -620,7 +60,16 @@ def _setup_mesh_fields(
620
60
  refined_cellsize: float,
621
61
  max_cellsize: float,
622
62
  ) -> None:
623
- """Set up mesh refinement fields."""
63
+ """Set up mesh refinement fields.
64
+
65
+ Args:
66
+ kernel: gmsh OCC kernel
67
+ groups: Physical group information
68
+ geometry: Extracted geometry data
69
+ stack: LayerStack with material properties
70
+ refined_cellsize: Fine mesh size near conductors (um)
71
+ max_cellsize: Coarse mesh size in air/dielectric (um)
72
+ """
624
73
  # Collect boundary lines from conductor surfaces
625
74
  boundary_lines = []
626
75
  for surface_info in groups["conductor_surfaces"].values():
@@ -679,282 +128,6 @@ def _setup_mesh_fields(
679
128
  gmsh_utils.finalize_mesh_fields(field_ids)
680
129
 
681
130
 
682
- def _generate_palace_config(
683
- groups: dict,
684
- ports: list[PalacePort],
685
- port_info: list,
686
- stack: LayerStack,
687
- output_path: Path,
688
- model_name: str,
689
- fmax: float,
690
- driven_config: DrivenConfig | None = None,
691
- ) -> Path:
692
- """Generate Palace config.json file.
693
-
694
- Args:
695
- groups: Physical group information from mesh generation
696
- ports: List of PalacePort objects
697
- port_info: Port metadata list
698
- stack: Layer stack for material properties
699
- output_path: Output directory path
700
- model_name: Base name for output files
701
- fmax: Maximum frequency (Hz) - used as fallback if driven_config not provided
702
- driven_config: Optional DrivenConfig for frequency sweep settings
703
- """
704
- # Use driven_config if provided, otherwise fall back to legacy parameters
705
- if driven_config is not None:
706
- solver_driven = driven_config.to_palace_config()
707
- else:
708
- # Legacy behavior - compute from fmax
709
- freq_step = fmax / 40e9
710
- solver_driven = {
711
- "Samples": [
712
- {
713
- "Type": "Linear",
714
- "MinFreq": 1.0, # 1 GHz
715
- "MaxFreq": fmax / 1e9,
716
- "FreqStep": freq_step,
717
- "SaveStep": 0,
718
- }
719
- ],
720
- "AdaptiveTol": 0.02,
721
- }
722
-
723
- config: dict[str, object] = {
724
- "Problem": {
725
- "Type": "Driven",
726
- "Verbose": 3,
727
- "Output": f"output/{model_name}",
728
- },
729
- "Model": {
730
- "Mesh": f"{model_name}.msh",
731
- "L0": 1e-6, # um
732
- "Refinement": {
733
- "UniformLevels": 0,
734
- "Tol": 1e-2,
735
- "MaxIts": 0,
736
- },
737
- },
738
- "Solver": {
739
- "Linear": {
740
- "Type": "Default",
741
- "KSPType": "GMRES",
742
- "Tol": 1e-6,
743
- "MaxIts": 400,
744
- },
745
- "Order": 2,
746
- "Device": "CPU",
747
- "Driven": solver_driven,
748
- },
749
- }
750
-
751
- # Build domains section
752
- materials: list[dict[str, object]] = []
753
- for material_name, info in groups["volumes"].items():
754
- mat_props = stack.materials.get(material_name, {})
755
- mat_entry: dict[str, object] = {"Attributes": [info["phys_group"]]}
756
-
757
- if material_name == "airbox":
758
- mat_entry["Permittivity"] = 1.0
759
- mat_entry["LossTan"] = 0.0
760
- else:
761
- mat_entry["Permittivity"] = mat_props.get("permittivity", 1.0)
762
- sigma = mat_props.get("conductivity", 0.0)
763
- if sigma > 0:
764
- mat_entry["Conductivity"] = sigma
765
- else:
766
- mat_entry["LossTan"] = mat_props.get("loss_tangent", 0.0)
767
-
768
- materials.append(mat_entry)
769
-
770
- config["Domains"] = {
771
- "Materials": materials,
772
- "Postprocessing": {"Energy": [], "Probe": []},
773
- }
774
-
775
- # Build boundaries section
776
- conductors: list[dict[str, object]] = []
777
- for name, info in groups["conductor_surfaces"].items():
778
- # Extract layer name from "layer_xy" or "layer_z"
779
- layer_name = name.rsplit("_", 1)[0]
780
- layer = stack.layers.get(layer_name)
781
- if layer:
782
- mat_props = stack.materials.get(layer.material, {})
783
- conductors.append(
784
- {
785
- "Attributes": [info["phys_group"]],
786
- "Conductivity": mat_props.get("conductivity", 5.8e7),
787
- "Thickness": layer.zmax - layer.zmin,
788
- }
789
- )
790
-
791
- lumped_ports: list[dict[str, object]] = []
792
- port_idx = 1
793
-
794
- for port in ports:
795
- port_key = f"P{port_idx}"
796
- if port_key in groups["port_surfaces"]:
797
- port_group = groups["port_surfaces"][port_key]
798
-
799
- if port.multi_element:
800
- # Multi-element port (CPW)
801
- if port_group.get("type") == "cpw":
802
- elements = [
803
- {
804
- "Attributes": [elem["phys_group"]],
805
- "Direction": elem["direction"],
806
- }
807
- for elem in port_group["elements"]
808
- ]
809
-
810
- lumped_ports.append(
811
- {
812
- "Index": port_idx,
813
- "R": port.impedance,
814
- "Excitation": port_idx if port.excited else False,
815
- "Elements": elements,
816
- }
817
- )
818
- else:
819
- # Single-element port
820
- direction = (
821
- "Z" if port.geometry == PortGeometry.VIA else port.direction.upper()
822
- )
823
- lumped_ports.append(
824
- {
825
- "Index": port_idx,
826
- "R": port.impedance,
827
- "Direction": direction,
828
- "Excitation": port_idx if port.excited else False,
829
- "Attributes": [port_group["phys_group"]],
830
- }
831
- )
832
- port_idx += 1
833
-
834
- boundaries: dict[str, object] = {
835
- "Conductivity": conductors,
836
- "LumpedPort": lumped_ports,
837
- }
838
-
839
- if "absorbing" in groups["boundary_surfaces"]:
840
- boundaries["Absorbing"] = {
841
- "Attributes": [groups["boundary_surfaces"]["absorbing"]["phys_group"]],
842
- "Order": 2,
843
- }
844
-
845
- config["Boundaries"] = boundaries
846
-
847
- # Write config file
848
- config_path = output_path / "config.json"
849
- with config_path.open("w") as f:
850
- json.dump(config, f, indent=4)
851
-
852
- # Write port information file
853
- port_info_path = output_path / "port_information.json"
854
- port_info_struct = {"ports": port_info, "unit": 1e-6, "name": model_name}
855
- with port_info_path.open("w") as f:
856
- json.dump(port_info_struct, f, indent=4)
857
-
858
- return config_path
859
-
860
-
861
- def _collect_mesh_stats() -> dict:
862
- """Collect mesh statistics from gmsh after mesh generation."""
863
- stats = {}
864
-
865
- # Get bounding box
866
- try:
867
- xmin, ymin, zmin, xmax, ymax, zmax = gmsh.model.getBoundingBox(-1, -1)
868
- stats["bbox"] = {
869
- "xmin": xmin,
870
- "ymin": ymin,
871
- "zmin": zmin,
872
- "xmax": xmax,
873
- "ymax": ymax,
874
- "zmax": zmax,
875
- }
876
- except Exception:
877
- pass
878
-
879
- # Get node count
880
- try:
881
- node_tags, _, _ = gmsh.model.mesh.getNodes()
882
- stats["nodes"] = len(node_tags)
883
- except Exception:
884
- pass
885
-
886
- # Get element counts and collect tet tags for quality
887
- tet_tags = []
888
- try:
889
- element_types, element_tags, _ = gmsh.model.mesh.getElements()
890
- total_elements = sum(len(tags) for tags in element_tags)
891
- stats["elements"] = total_elements
892
-
893
- # Count tetrahedra (type 4) and save tags
894
- for etype, tags in zip(element_types, element_tags):
895
- if etype == 4: # 4-node tetrahedron
896
- stats["tetrahedra"] = len(tags)
897
- tet_tags = list(tags)
898
- except Exception:
899
- pass
900
-
901
- # Get mesh quality for tetrahedra
902
- if tet_tags:
903
- # Gamma: inscribed/circumscribed radius ratio (shape quality)
904
- try:
905
- qualities = gmsh.model.mesh.getElementQualities(tet_tags, "gamma")
906
- if len(qualities) > 0:
907
- stats["quality"] = {
908
- "min": round(min(qualities), 3),
909
- "max": round(max(qualities), 3),
910
- "mean": round(sum(qualities) / len(qualities), 3),
911
- }
912
- except Exception:
913
- pass
914
-
915
- # SICN: Signed Inverse Condition Number (negative = invalid element)
916
- try:
917
- sicn = gmsh.model.mesh.getElementQualities(tet_tags, "minSICN")
918
- if len(sicn) > 0:
919
- sicn_min = min(sicn)
920
- invalid_count = sum(1 for s in sicn if s < 0)
921
- stats["sicn"] = {
922
- "min": round(sicn_min, 3),
923
- "mean": round(sum(sicn) / len(sicn), 3),
924
- "invalid": invalid_count,
925
- }
926
- except Exception:
927
- pass
928
-
929
- # Edge lengths
930
- try:
931
- min_edges = gmsh.model.mesh.getElementQualities(tet_tags, "minEdge")
932
- max_edges = gmsh.model.mesh.getElementQualities(tet_tags, "maxEdge")
933
- if len(min_edges) > 0 and len(max_edges) > 0:
934
- stats["edge_length"] = {
935
- "min": round(min(min_edges), 3),
936
- "max": round(max(max_edges), 3),
937
- }
938
- except Exception:
939
- pass
940
-
941
- # Get physical groups with tags
942
- try:
943
- groups = {"volumes": [], "surfaces": []}
944
- for dim, tag in gmsh.model.getPhysicalGroups():
945
- name = gmsh.model.getPhysicalName(dim, tag)
946
- entry = {"name": name, "tag": tag}
947
- if dim == 3:
948
- groups["volumes"].append(entry)
949
- elif dim == 2:
950
- groups["surfaces"].append(entry)
951
- stats["groups"] = groups
952
- except Exception:
953
- pass
954
-
955
- return stats
956
-
957
-
958
131
  def generate_mesh(
959
132
  component,
960
133
  stack: LayerStack,
@@ -1017,13 +190,13 @@ def generate_mesh(
1017
190
  try:
1018
191
  # Add geometry
1019
192
  logger.info("Adding metals...")
1020
- metal_tags = _add_metals(kernel, geometry, stack)
193
+ metal_tags = add_metals(kernel, geometry, stack)
1021
194
 
1022
195
  logger.info("Adding ports...")
1023
- port_tags, port_info = _add_ports(kernel, ports, stack)
196
+ port_tags, port_info = add_ports(kernel, ports, stack)
1024
197
 
1025
198
  logger.info("Adding dielectrics...")
1026
- dielectric_tags = _add_dielectrics(kernel, geometry, stack, margin, air_margin)
199
+ dielectric_tags = add_dielectrics(kernel, geometry, stack, margin, air_margin)
1027
200
 
1028
201
  # Fragment geometry
1029
202
  logger.info("Fragmenting geometry...")
@@ -1031,7 +204,7 @@ def generate_mesh(
1031
204
 
1032
205
  # Assign physical groups
1033
206
  logger.info("Assigning physical groups...")
1034
- groups = _assign_physical_groups(
207
+ groups = assign_physical_groups(
1035
208
  kernel,
1036
209
  metal_tags,
1037
210
  dielectric_tags,
@@ -1057,7 +230,7 @@ def generate_mesh(
1057
230
  gmsh.model.mesh.generate(3)
1058
231
 
1059
232
  # Collect mesh statistics
1060
- mesh_stats = _collect_mesh_stats()
233
+ mesh_stats = collect_mesh_stats()
1061
234
 
1062
235
  # Save mesh
1063
236
  gmsh.option.setNumber("Mesh.Binary", 0)
@@ -1071,8 +244,15 @@ def generate_mesh(
1071
244
  config_path = None
1072
245
  if write_config:
1073
246
  logger.info("Generating Palace config...")
1074
- config_path = _generate_palace_config(
1075
- groups, ports, port_info, stack, output_dir, model_name, fmax, driven_config
247
+ config_path = generate_palace_config(
248
+ groups,
249
+ ports,
250
+ port_info,
251
+ stack,
252
+ output_dir,
253
+ model_name,
254
+ fmax,
255
+ driven_config,
1076
256
  )
1077
257
 
1078
258
  finally:
@@ -1094,44 +274,5 @@ def generate_mesh(
1094
274
  return result
1095
275
 
1096
276
 
1097
- def write_config(
1098
- mesh_result: MeshResult,
1099
- stack: LayerStack,
1100
- ports: list[PalacePort],
1101
- driven_config: DrivenConfig | None = None,
1102
- ) -> Path:
1103
- """Write Palace config.json from a MeshResult.
1104
-
1105
- Use this to generate config separately after mesh().
1106
-
1107
- Args:
1108
- mesh_result: Result from generate_mesh(write_config=False)
1109
- stack: LayerStack for material properties
1110
- ports: List of PalacePort objects
1111
- driven_config: Optional DrivenConfig for frequency sweep settings
1112
-
1113
- Returns:
1114
- Path to the generated config.json
1115
-
1116
- Example:
1117
- >>> result = sim.mesh(output_dir, write_config=False)
1118
- >>> config_path = write_config(result, stack, ports, driven_config)
1119
- """
1120
- if not mesh_result.groups:
1121
- raise ValueError("MeshResult has no groups data. Was it generated with write_config=False?")
1122
-
1123
- config_path = _generate_palace_config(
1124
- groups=mesh_result.groups,
1125
- ports=ports,
1126
- port_info=mesh_result.port_info,
1127
- stack=stack,
1128
- output_path=mesh_result.output_dir,
1129
- model_name=mesh_result.model_name,
1130
- fmax=mesh_result.fmax,
1131
- driven_config=driven_config,
1132
- )
1133
-
1134
- # Update the mesh_result with the config path
1135
- mesh_result.config_path = config_path
1136
-
1137
- return config_path
277
+ # Re-export write_config from config_generator for backward compatibility
278
+ __all__ = ["GeometryData", "MeshResult", "generate_mesh", "write_config"]