gsim 0.0.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.
@@ -0,0 +1,956 @@
1
+ """Mesh generator for Palace EM simulation.
2
+
3
+ This module generates meshes directly from palace-api data structures,
4
+ replacing the gds2palace backend with a cleaner implementation.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import logging
11
+ import math
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+ from typing import TYPE_CHECKING
15
+
16
+ import gmsh
17
+
18
+ from . import gmsh_utils
19
+
20
+ if TYPE_CHECKING:
21
+ from gsim.palace.ports.config import PalacePort
22
+ from gsim.palace.stack import LayerStack
23
+
24
+ from gsim.palace.ports.config import PortGeometry
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
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
+ @dataclass
39
+ class MeshResult:
40
+ """Result from mesh generation."""
41
+
42
+ mesh_path: Path
43
+ config_path: Path | None = None
44
+ 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
606
+
607
+
608
+ def _setup_mesh_fields(
609
+ kernel,
610
+ groups: dict,
611
+ geometry: GeometryData,
612
+ stack: LayerStack,
613
+ refined_cellsize: float,
614
+ max_cellsize: float,
615
+ ) -> None:
616
+ """Set up mesh refinement fields."""
617
+ # Collect boundary lines from conductor surfaces
618
+ boundary_lines = []
619
+ for surface_info in groups["conductor_surfaces"].values():
620
+ for tag in surface_info["tags"]:
621
+ lines = gmsh_utils.get_boundary_lines(tag, kernel)
622
+ boundary_lines.extend(lines)
623
+
624
+ # Add port boundaries
625
+ for surface_info in groups["port_surfaces"].values():
626
+ if surface_info.get("type") == "cpw":
627
+ # CPW port: get tags from each element
628
+ for elem in surface_info["elements"]:
629
+ for tag in elem["tags"]:
630
+ lines = gmsh_utils.get_boundary_lines(tag, kernel)
631
+ boundary_lines.extend(lines)
632
+ else:
633
+ # Regular port
634
+ for tag in surface_info["tags"]:
635
+ lines = gmsh_utils.get_boundary_lines(tag, kernel)
636
+ boundary_lines.extend(lines)
637
+
638
+ # Setup main refinement field
639
+ field_ids = []
640
+ if boundary_lines:
641
+ field_id = gmsh_utils.setup_mesh_refinement(
642
+ boundary_lines, refined_cellsize, max_cellsize
643
+ )
644
+ field_ids.append(field_id)
645
+
646
+ # Add box refinement for dielectrics based on permittivity
647
+ xmin, ymin, xmax, ymax = geometry.bbox
648
+ field_counter = 10
649
+
650
+ for dielectric in stack.dielectrics:
651
+ material_name = dielectric["material"]
652
+ material_props = stack.materials.get(material_name, {})
653
+ permittivity = material_props.get("permittivity", 1.0)
654
+
655
+ if permittivity > 1:
656
+ local_max = max_cellsize / math.sqrt(permittivity)
657
+ gmsh_utils.setup_box_refinement(
658
+ field_counter,
659
+ xmin,
660
+ ymin,
661
+ dielectric["zmin"],
662
+ xmax,
663
+ ymax,
664
+ dielectric["zmax"],
665
+ local_max,
666
+ max_cellsize,
667
+ )
668
+ field_ids.append(field_counter)
669
+ field_counter += 1
670
+
671
+ if field_ids:
672
+ gmsh_utils.finalize_mesh_fields(field_ids)
673
+
674
+
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
+ def generate_mesh(
835
+ component,
836
+ stack: LayerStack,
837
+ ports: list[PalacePort],
838
+ output_dir: str | Path,
839
+ model_name: str = "palace",
840
+ refined_mesh_size: float = 5.0,
841
+ max_mesh_size: float = 300.0,
842
+ margin: float = 50.0,
843
+ air_margin: float = 50.0,
844
+ fmax: float = 100e9,
845
+ show_gui: bool = False,
846
+ ) -> MeshResult:
847
+ """Generate mesh for Palace EM simulation.
848
+
849
+ Args:
850
+ component: gdsfactory Component
851
+ stack: LayerStack from palace-api
852
+ ports: List of PalacePort objects (single and multi-element)
853
+ output_dir: Directory for output files
854
+ model_name: Base name for output files
855
+ refined_mesh_size: Mesh size near conductors (um)
856
+ max_mesh_size: Max mesh size in air/dielectric (um)
857
+ margin: XY margin around design (um)
858
+ air_margin: Air box margin (um)
859
+ fmax: Max frequency for config (Hz)
860
+ show_gui: Show gmsh GUI during meshing
861
+
862
+ Returns:
863
+ MeshResult with paths and metadata
864
+ """
865
+ output_dir = Path(output_dir)
866
+ output_dir.mkdir(parents=True, exist_ok=True)
867
+
868
+ msh_path = output_dir / f"{model_name}.msh"
869
+
870
+ # Extract geometry
871
+ logger.info("Extracting geometry...")
872
+ geometry = extract_geometry(component, stack)
873
+ logger.info(" Polygons: %s", len(geometry.polygons))
874
+ logger.info(" Bbox: %s", geometry.bbox)
875
+
876
+ # Initialize gmsh
877
+ gmsh.initialize()
878
+ gmsh.option.setNumber("General.Verbosity", 3)
879
+
880
+ if "palace_mesh" in gmsh.model.list():
881
+ gmsh.model.setCurrent("palace_mesh")
882
+ gmsh.model.remove()
883
+ gmsh.model.add("palace_mesh")
884
+
885
+ kernel = gmsh.model.occ
886
+ config_path: Path | None = None
887
+ port_info: list = []
888
+
889
+ try:
890
+ # Add geometry
891
+ logger.info("Adding metals...")
892
+ metal_tags = _add_metals(kernel, geometry, stack)
893
+
894
+ logger.info("Adding ports...")
895
+ port_tags, port_info = _add_ports(kernel, ports, stack)
896
+
897
+ logger.info("Adding dielectrics...")
898
+ dielectric_tags = _add_dielectrics(kernel, geometry, stack, margin, air_margin)
899
+
900
+ # Fragment geometry
901
+ logger.info("Fragmenting geometry...")
902
+ geom_dimtags, geom_map = gmsh_utils.fragment_all(kernel)
903
+
904
+ # Assign physical groups
905
+ logger.info("Assigning physical groups...")
906
+ groups = _assign_physical_groups(
907
+ kernel,
908
+ metal_tags,
909
+ dielectric_tags,
910
+ port_tags,
911
+ port_info,
912
+ geom_dimtags,
913
+ geom_map,
914
+ stack,
915
+ )
916
+
917
+ # Setup mesh fields
918
+ logger.info("Setting up mesh refinement...")
919
+ _setup_mesh_fields(
920
+ kernel, groups, geometry, stack, refined_mesh_size, max_mesh_size
921
+ )
922
+
923
+ # Show GUI if requested
924
+ if show_gui:
925
+ gmsh.fltk.run()
926
+
927
+ # Generate mesh
928
+ logger.info("Generating mesh...")
929
+ gmsh.model.mesh.generate(3)
930
+
931
+ # Save mesh
932
+ gmsh.option.setNumber("Mesh.Binary", 0)
933
+ gmsh.option.setNumber("Mesh.SaveAll", 0)
934
+ gmsh.option.setNumber("Mesh.MshFileVersion", 2.2)
935
+ gmsh.write(str(msh_path))
936
+
937
+ logger.info("Mesh saved: %s", msh_path)
938
+
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
+ )
944
+
945
+ finally:
946
+ gmsh.clear()
947
+ gmsh.finalize()
948
+
949
+ # Build result
950
+ result = MeshResult(
951
+ mesh_path=msh_path,
952
+ config_path=config_path,
953
+ port_info=port_info,
954
+ )
955
+
956
+ return result