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,602 @@
1
+ """Extract layer stack from gdsfactory PDK and convert to YAML format.
2
+
3
+ This module reads a PDK's LayerStack and generates a YAML stack file
4
+ that can be used for Palace EM simulation.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ from dataclasses import dataclass, field
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ import yaml
15
+ from gdsfactory.technology import LayerLevel
16
+ from gdsfactory.technology import LayerStack as GfLayerStack
17
+
18
+ from gsim.palace.stack.materials import (
19
+ MATERIALS_DB,
20
+ get_material_properties,
21
+ )
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ @dataclass
27
+ class Layer:
28
+ """Layer information for Palace simulation."""
29
+
30
+ name: str
31
+ gds_layer: tuple[int, int] # (layer, datatype)
32
+ zmin: float # um
33
+ zmax: float # um
34
+ thickness: float # um
35
+ material: str
36
+ layer_type: str # "conductor", "via", "dielectric", "substrate"
37
+
38
+ # Mesh resolution control
39
+ # Options: "fine", "medium", "coarse" or a float in um
40
+ mesh_resolution: str | float = "medium"
41
+
42
+ def get_mesh_size(self, base_size: float = 1.0) -> float:
43
+ """Get mesh size in um for this layer.
44
+
45
+ Args:
46
+ base_size: Base mesh size for "medium" resolution
47
+
48
+ Returns:
49
+ Mesh size in um
50
+ """
51
+ if isinstance(self.mesh_resolution, int | float):
52
+ return float(self.mesh_resolution)
53
+
54
+ resolution_map = {
55
+ "fine": base_size * 0.5,
56
+ "medium": base_size,
57
+ "coarse": base_size * 2.0,
58
+ }
59
+ return resolution_map.get(self.mesh_resolution, base_size)
60
+
61
+ def to_dict(self) -> dict:
62
+ """Convert to dictionary for YAML output."""
63
+ return {
64
+ "gds_layer": list(self.gds_layer),
65
+ "zmin": round(self.zmin, 4),
66
+ "zmax": round(self.zmax, 4),
67
+ "thickness": round(self.thickness, 4),
68
+ "material": self.material,
69
+ "type": self.layer_type,
70
+ "mesh_resolution": self.mesh_resolution,
71
+ }
72
+
73
+
74
+ @dataclass
75
+ class ValidationResult:
76
+ """Result of stack validation."""
77
+
78
+ valid: bool
79
+ errors: list[str] = field(default_factory=list)
80
+ warnings: list[str] = field(default_factory=list)
81
+
82
+ def __bool__(self) -> bool:
83
+ return self.valid
84
+
85
+ def __str__(self) -> str:
86
+ lines = []
87
+ if self.valid:
88
+ lines.append("Stack validation: PASSED")
89
+ else:
90
+ lines.append("Stack validation: FAILED")
91
+ if self.errors:
92
+ lines.append("Errors:")
93
+ lines.extend([f" - {e}" for e in self.errors])
94
+ if self.warnings:
95
+ lines.append("Warnings:")
96
+ lines.extend([f" - {w}" for w in self.warnings])
97
+ return "\n".join(lines)
98
+
99
+
100
+ @dataclass
101
+ class LayerStack:
102
+ """Complete layer stack for Palace simulation."""
103
+
104
+ pdk_name: str
105
+ units: str = "um"
106
+ layers: dict[str, Layer] = field(default_factory=dict)
107
+ materials: dict[str, dict] = field(default_factory=dict)
108
+ dielectrics: list[dict] = field(default_factory=list)
109
+ simulation: dict = field(default_factory=dict)
110
+
111
+ def validate(self, tolerance: float = 0.001) -> ValidationResult:
112
+ """Validate the layer stack for simulation readiness.
113
+
114
+ Checks:
115
+ 1. Z-axis continuity: no gaps in dielectric regions
116
+ 2. Material coverage: all materials have properties defined
117
+ 3. Layer coverage: all conductor/via layers are within dielectric envelope
118
+ 4. No negative thicknesses
119
+
120
+ Args:
121
+ tolerance: Tolerance for z-coordinate comparisons (um)
122
+
123
+ Returns:
124
+ ValidationResult with valid flag, errors, and warnings
125
+ """
126
+ errors = []
127
+ warnings = []
128
+
129
+ # 1. Check all materials have required properties
130
+ materials_used = set()
131
+
132
+ # Collect materials from layers
133
+ for name, layer in self.layers.items():
134
+ materials_used.add(layer.material)
135
+ # Check for invalid thickness
136
+ if layer.thickness < 0:
137
+ errors.append(
138
+ f"Layer '{name}' has negative thickness: {layer.thickness}"
139
+ )
140
+ if layer.thickness == 0:
141
+ warnings.append(f"Layer '{name}' has zero thickness")
142
+
143
+ # Collect materials from dielectrics
144
+ for d in self.dielectrics:
145
+ materials_used.add(d["material"])
146
+
147
+ # Check each material has properties
148
+ for mat in materials_used:
149
+ if mat not in self.materials:
150
+ errors.append(
151
+ f"Material '{mat}' used but not defined in materials dict"
152
+ )
153
+ else:
154
+ props = self.materials[mat]
155
+ mat_type = props.get("type", "unknown")
156
+ if mat_type == "unknown":
157
+ warnings.append(f"Material '{mat}' has unknown type")
158
+ elif mat_type == "conductor":
159
+ if "conductivity" not in props:
160
+ errors.append(
161
+ f"Conductor material '{mat}' missing conductivity"
162
+ )
163
+ elif mat_type == "dielectric" and "permittivity" not in props:
164
+ errors.append(f"Dielectric material '{mat}' missing permittivity")
165
+
166
+ # 2. Check z-axis continuity of dielectrics
167
+ if self.dielectrics:
168
+ # Sort dielectrics by zmin
169
+ sorted_dielectrics = sorted(self.dielectrics, key=lambda d: d["zmin"])
170
+
171
+ # Check for gaps between dielectric regions
172
+ for i in range(len(sorted_dielectrics) - 1):
173
+ current = sorted_dielectrics[i]
174
+ next_d = sorted_dielectrics[i + 1]
175
+
176
+ gap = next_d["zmin"] - current["zmax"]
177
+ if gap > tolerance:
178
+ errors.append(
179
+ f"Z-axis gap between '{current['name']}' "
180
+ f"(zmax={current['zmax']:.4f}) and '{next_d['name']}' "
181
+ f"(zmin={next_d['zmin']:.4f}): gap={gap:.4f} um"
182
+ )
183
+ elif gap < -tolerance:
184
+ # Overlap - this might be intentional
185
+ warnings.append(
186
+ f"Z-axis overlap between '{current['name']}' and "
187
+ f"'{next_d['name']}': overlap={-gap:.4f} um"
188
+ )
189
+
190
+ # Get overall dielectric envelope
191
+ z_min_dielectric = sorted_dielectrics[0]["zmin"]
192
+ z_max_dielectric = sorted_dielectrics[-1]["zmax"]
193
+ else:
194
+ errors.append("No dielectric regions defined")
195
+ z_min_dielectric = 0
196
+ z_max_dielectric = 0
197
+
198
+ # 3. Check all conductor/via layers are within dielectric envelope
199
+ for name, layer in self.layers.items():
200
+ if layer.layer_type in ("conductor", "via"):
201
+ if layer.zmin < z_min_dielectric - tolerance:
202
+ errors.append(
203
+ f"Layer '{name}' extends below dielectric envelope: "
204
+ f"layer zmin={layer.zmin:.4f}, dielectric "
205
+ f"zmin={z_min_dielectric:.4f}"
206
+ )
207
+ if layer.zmax > z_max_dielectric + tolerance:
208
+ errors.append(
209
+ f"Layer '{name}' extends above dielectric envelope: "
210
+ f"layer zmax={layer.zmax:.4f}, dielectric "
211
+ f"zmax={z_max_dielectric:.4f}"
212
+ )
213
+
214
+ # 4. Check we have at least substrate, oxide, and air
215
+ dielectric_names = {d["name"] for d in self.dielectrics}
216
+ if "substrate" not in dielectric_names:
217
+ warnings.append("No 'substrate' dielectric region defined")
218
+ if "air_box" not in dielectric_names:
219
+ warnings.append("No 'air_box' dielectric region defined")
220
+
221
+ valid = len(errors) == 0
222
+ return ValidationResult(valid=valid, errors=errors, warnings=warnings)
223
+
224
+ def get_z_range(self) -> tuple[float, float]:
225
+ """Get the full z-range of the stack (substrate bottom to air top)."""
226
+ if not self.dielectrics:
227
+ return (0.0, 0.0)
228
+ z_min = min(d["zmin"] for d in self.dielectrics)
229
+ z_max = max(d["zmax"] for d in self.dielectrics)
230
+ return (z_min, z_max)
231
+
232
+ def get_conductor_layers(self) -> dict[str, Layer]:
233
+ """Get all conductor layers."""
234
+ return {
235
+ n: layer
236
+ for n, layer in self.layers.items()
237
+ if layer.layer_type == "conductor"
238
+ }
239
+
240
+ def get_via_layers(self) -> dict[str, Layer]:
241
+ """Get all via layers."""
242
+ return {
243
+ n: layer for n, layer in self.layers.items() if layer.layer_type == "via"
244
+ }
245
+
246
+ def to_dict(self) -> dict:
247
+ """Convert to dictionary for YAML output."""
248
+ return {
249
+ "version": "1.0",
250
+ "pdk": self.pdk_name,
251
+ "units": self.units,
252
+ "materials": self.materials,
253
+ "layers": {name: layer.to_dict() for name, layer in self.layers.items()},
254
+ "dielectrics": self.dielectrics,
255
+ "simulation": self.simulation,
256
+ }
257
+
258
+ def to_yaml(self, path: Path | None = None) -> str:
259
+ """Convert to YAML string and optionally write to file.
260
+
261
+ Args:
262
+ path: Optional path to write YAML file
263
+
264
+ Returns:
265
+ YAML string
266
+ """
267
+ yaml_str = yaml.dump(
268
+ self.to_dict(),
269
+ default_flow_style=False,
270
+ sort_keys=False,
271
+ allow_unicode=True,
272
+ )
273
+
274
+ if path:
275
+ path = Path(path)
276
+ path.parent.mkdir(parents=True, exist_ok=True)
277
+ path.write_text(yaml_str)
278
+ logger.info("Stack written to: %s", path)
279
+
280
+ return yaml_str
281
+
282
+
283
+ def _get_gds_layer_tuple(layer_level: LayerLevel) -> tuple[int, int]:
284
+ """Extract GDS layer tuple from LayerLevel.
285
+
286
+ The layer can be specified in various ways in gdsfactory:
287
+ - tuple: (8, 0)
288
+ - int: 8
289
+ - LogicalLayer with nested layer enum
290
+ - kfactory LayerEnum with .layer and .datatype
291
+ """
292
+ layer: Any = layer_level.layer
293
+
294
+ # Handle different layer specifications
295
+ if isinstance(layer, tuple):
296
+ return (int(layer[0]), int(layer[1]))
297
+
298
+ if isinstance(layer, int):
299
+ return (int(layer), 0)
300
+
301
+ # Handle LogicalLayer (gdsfactory.technology.layer_stack.LogicalLayer)
302
+ # Structure: layer_level.layer (LogicalLayer) -> .layer (Enum) -> .layer/.datatype
303
+ # (int)
304
+ if hasattr(layer, "layer"):
305
+ inner = layer.layer
306
+ # Check if inner has layer/datatype (kfactory/gdsfactory enum)
307
+ if hasattr(inner, "layer") and hasattr(inner, "datatype"):
308
+ return (int(inner.layer), int(inner.datatype))
309
+ # Check if inner itself has the values
310
+ if isinstance(inner, int):
311
+ datatype = getattr(layer, "datatype", 0)
312
+ return (int(inner), int(datatype) if datatype else 0)
313
+ # Recurse one more level if needed
314
+ if hasattr(inner, "layer"):
315
+ innermost = inner.layer
316
+ if isinstance(innermost, int):
317
+ datatype = getattr(inner, "datatype", 0)
318
+ return (int(innermost), int(datatype) if datatype else 0)
319
+
320
+ # Direct enum with layer/datatype
321
+ if hasattr(layer, "layer") and hasattr(layer, "datatype"):
322
+ return (int(layer.layer), int(layer.datatype))
323
+
324
+ # Enum with tuple value
325
+ if hasattr(layer, "value"):
326
+ if isinstance(layer.value, tuple):
327
+ return (int(layer.value[0]), int(layer.value[1]))
328
+ if isinstance(layer.value, int):
329
+ return (int(layer.value), 0)
330
+
331
+ # String format "8/0"
332
+ if isinstance(layer, str):
333
+ if "/" in layer:
334
+ parts = layer.split("/")
335
+ return (int(parts[0]), int(parts[1]))
336
+ return (0, 0)
337
+
338
+ # Fallback
339
+ try:
340
+ return (int(layer), 0)
341
+ except (TypeError, ValueError):
342
+ logger.warning("Could not parse layer %s, using (0, 0)", layer)
343
+ return (0, 0)
344
+
345
+
346
+ def _classify_layer_type(layer_name: str, material: str) -> str:
347
+ """Classify a layer as conductor, via, dielectric, or substrate.
348
+
349
+ Args:
350
+ layer_name: Name of the layer (e.g., "metal1", "via1", "substrate")
351
+ material: Material name
352
+
353
+ Returns:
354
+ Layer type string
355
+ """
356
+ name_lower = layer_name.lower()
357
+ material_lower = material.lower()
358
+
359
+ # Check for via layers
360
+ if "via" in name_lower:
361
+ return "via"
362
+
363
+ # Check for metal layers
364
+ if any(
365
+ m in name_lower for m in ["metal", "topmetal", "m1", "m2", "m3", "m4", "m5"]
366
+ ):
367
+ return "conductor"
368
+
369
+ # Check for substrate
370
+ if "substrate" in name_lower or name_lower == "sub":
371
+ return "substrate"
372
+
373
+ # Check by material
374
+ props = get_material_properties(material)
375
+ if props:
376
+ if props.type == "conductor":
377
+ return "conductor"
378
+ if props.type == "semiconductor" and "substrate" in name_lower:
379
+ return "substrate"
380
+ if props.type == "dielectric":
381
+ return "dielectric"
382
+
383
+ # Check for common conductor materials
384
+ if material_lower in ["aluminum", "copper", "tungsten", "gold", "al", "cu", "w"]:
385
+ return "conductor"
386
+
387
+ # Check for poly
388
+ if "poly" in name_lower:
389
+ return "conductor"
390
+
391
+ # Default to dielectric
392
+ return "dielectric"
393
+
394
+
395
+ def extract_layer_stack(
396
+ gf_layer_stack: GfLayerStack,
397
+ pdk_name: str = "unknown",
398
+ substrate_thickness: float = 2.0,
399
+ air_above: float = 200.0,
400
+ boundary_margin: float = 30.0,
401
+ include_substrate: bool = False,
402
+ ) -> LayerStack:
403
+ """Extract layer stack from a gdsfactory LayerStack.
404
+
405
+ Args:
406
+ gf_layer_stack: gdsfactory LayerStack object
407
+ pdk_name: Name of the PDK (for documentation)
408
+ substrate_thickness: Thickness of substrate in um (default: 2.0)
409
+ air_above: Height of air box above top metal in um (default: 200)
410
+ boundary_margin: Lateral margin from GDS bbox in um (default: 30)
411
+ include_substrate: Whether to include lossy substrate (default: False).
412
+ When False, matches gds2palace "nosub" behavior for RF simulation.
413
+
414
+ Returns:
415
+ LayerStack object for Palace simulation
416
+ """
417
+ stack = LayerStack(pdk_name=pdk_name)
418
+
419
+ # Track z-range for dielectric regions
420
+ z_min_overall = float("inf")
421
+ z_max_overall = float("-inf")
422
+
423
+ # Collect materials used
424
+ materials_used: set[str] = set()
425
+
426
+ # Extract each layer
427
+ for layer_name, layer_level in gf_layer_stack.layers.items():
428
+ # Get layer properties
429
+ zmin = layer_level.zmin if layer_level.zmin is not None else 0.0
430
+ thickness = layer_level.thickness if layer_level.thickness is not None else 0.0
431
+ zmax = zmin + thickness
432
+ material = layer_level.material if layer_level.material else "unknown"
433
+
434
+ # Get GDS layer tuple
435
+ gds_layer = _get_gds_layer_tuple(layer_level)
436
+
437
+ # Classify layer type
438
+ layer_type = _classify_layer_type(layer_name, material)
439
+
440
+ # Skip substrate layers when include_substrate=False (matches gds2palace
441
+ # "nosub").
442
+ if layer_type == "substrate" and not include_substrate:
443
+ continue
444
+
445
+ # Create layer
446
+ layer = Layer(
447
+ name=layer_name,
448
+ gds_layer=gds_layer,
449
+ zmin=zmin,
450
+ zmax=zmax,
451
+ thickness=thickness,
452
+ material=material,
453
+ layer_type=layer_type,
454
+ )
455
+
456
+ stack.layers[layer_name] = layer
457
+ materials_used.add(material)
458
+
459
+ # Update z-range (only for non-substrate layers to get metal stack extent)
460
+ if layer_type != "substrate":
461
+ z_min_overall = min(z_min_overall, zmin)
462
+ z_max_overall = max(z_max_overall, zmax)
463
+
464
+ # Add material properties
465
+ for material in materials_used:
466
+ props = get_material_properties(material)
467
+ if props:
468
+ stack.materials[material] = props.to_dict()
469
+ else:
470
+ # Unknown material - add placeholder
471
+ stack.materials[material] = {
472
+ "type": "unknown",
473
+ "note": "Material not in database, please add properties manually",
474
+ }
475
+
476
+ # Always add air for the air box
477
+ if "air" not in stack.materials:
478
+ stack.materials["air"] = MATERIALS_DB["air"].to_dict()
479
+
480
+ # Add SiO2 for inter-layer dielectric if not present
481
+ if "SiO2" not in stack.materials:
482
+ stack.materials["SiO2"] = MATERIALS_DB["SiO2"].to_dict()
483
+
484
+ # Generate dielectric regions
485
+ if include_substrate:
486
+ # 1. Substrate below z=0 (lossy silicon)
487
+ stack.dielectrics.append(
488
+ {
489
+ "name": "substrate",
490
+ "zmin": -substrate_thickness,
491
+ "zmax": 0.0,
492
+ "material": "silicon",
493
+ }
494
+ )
495
+ if "silicon" not in stack.materials:
496
+ stack.materials["silicon"] = MATERIALS_DB["silicon"].to_dict()
497
+ oxide_zmin = 0.0
498
+ else:
499
+ # No substrate - extend oxide down slightly (matches gds2palace "nosub")
500
+ # This provides a dielectric spacing below the bottom metal
501
+ oxide_zmin = -substrate_thickness
502
+
503
+ # 2. Inter-layer dielectric (simplified: one big oxide region)
504
+ # In reality, should fill gaps between metals
505
+ stack.dielectrics.append(
506
+ {
507
+ "name": "oxide",
508
+ "zmin": oxide_zmin,
509
+ "zmax": z_max_overall,
510
+ "material": "SiO2",
511
+ }
512
+ )
513
+
514
+ # 3. Passivation layer on top of oxide (matches gds2palace IHP SG13G2)
515
+ passive_thickness = 0.4 # um, from gds2palace XML
516
+ stack.dielectrics.append(
517
+ {
518
+ "name": "passive",
519
+ "zmin": z_max_overall,
520
+ "zmax": z_max_overall + passive_thickness,
521
+ "material": "passive",
522
+ }
523
+ )
524
+ if "passive" not in stack.materials:
525
+ stack.materials["passive"] = MATERIALS_DB["passive"].to_dict()
526
+
527
+ # 4. Air above passivation
528
+ stack.dielectrics.append(
529
+ {
530
+ "name": "air_box",
531
+ "zmin": z_max_overall + passive_thickness,
532
+ "zmax": z_max_overall + passive_thickness + air_above,
533
+ "material": "air",
534
+ }
535
+ )
536
+
537
+ # Simulation settings
538
+ stack.simulation = {
539
+ "boundary_margin": boundary_margin,
540
+ "air_above": air_above,
541
+ "substrate_thickness": substrate_thickness,
542
+ "include_substrate": include_substrate,
543
+ }
544
+
545
+ return stack
546
+
547
+
548
+ def extract_from_pdk(
549
+ pdk_module,
550
+ output_path: Path | None = None,
551
+ **kwargs,
552
+ ) -> LayerStack:
553
+ """Extract layer stack from a PDK module or PDK object.
554
+
555
+ Args:
556
+ pdk_module: PDK module (e.g., ihp, sky130) or gdsfactory Pdk object
557
+ output_path: Optional path to write YAML file
558
+ **kwargs: Additional arguments passed to extract_layer_stack
559
+
560
+ Returns:
561
+ LayerStack object for Palace simulation
562
+ """
563
+ # Get PDK name - handle both module and Pdk object
564
+ pdk_name = "unknown"
565
+
566
+ # Direct .name attribute (gdsfactory Pdk object)
567
+ if hasattr(pdk_module, "name") and isinstance(pdk_module.name, str):
568
+ pdk_name = pdk_module.name
569
+ # Module with PDK.name
570
+ elif hasattr(pdk_module, "PDK") and hasattr(pdk_module.PDK, "name"):
571
+ pdk_name = pdk_module.PDK.name
572
+ # Module __name__
573
+ elif hasattr(pdk_module, "__name__"):
574
+ pdk_name = pdk_module.__name__
575
+
576
+ # Get layer stack from PDK - handle both module and Pdk object
577
+ gf_layer_stack = None
578
+
579
+ # Direct layer_stack attribute (gdsfactory Pdk object)
580
+ if hasattr(pdk_module, "layer_stack") and pdk_module.layer_stack is not None:
581
+ gf_layer_stack = pdk_module.layer_stack
582
+ # Module with LAYER_STACK
583
+ elif hasattr(pdk_module, "LAYER_STACK"):
584
+ gf_layer_stack = pdk_module.LAYER_STACK
585
+ # Module with get_layer_stack()
586
+ elif hasattr(pdk_module, "get_layer_stack"):
587
+ gf_layer_stack = pdk_module.get_layer_stack()
588
+ # Module with PDK.layer_stack
589
+ elif hasattr(pdk_module, "PDK") and hasattr(pdk_module.PDK, "layer_stack"):
590
+ gf_layer_stack = pdk_module.PDK.layer_stack
591
+
592
+ if gf_layer_stack is None:
593
+ raise ValueError(f"Could not find layer stack in PDK: {pdk_module}")
594
+
595
+ # Extract
596
+ stack = extract_layer_stack(gf_layer_stack, pdk_name=pdk_name, **kwargs)
597
+
598
+ # Write to file if path provided
599
+ if output_path:
600
+ stack.to_yaml(output_path)
601
+
602
+ return stack