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