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.
@@ -0,0 +1,557 @@
1
+ """Eigenmode simulation class for resonance/mode finding.
2
+
3
+ This module provides the EigenmodeSim class for finding resonant
4
+ frequencies and mode shapes.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ import tempfile
11
+ from pathlib import Path
12
+ from typing import Any, Literal
13
+
14
+ from pydantic import BaseModel, ConfigDict, Field, PrivateAttr
15
+
16
+ from gsim.common import Geometry, LayerStack
17
+ from gsim.palace.base import PalaceSimMixin
18
+ from gsim.palace.models import (
19
+ CPWPortConfig,
20
+ EigenmodeConfig,
21
+ MaterialConfig,
22
+ MeshConfig,
23
+ NumericalConfig,
24
+ PortConfig,
25
+ SimulationResult,
26
+ ValidationResult,
27
+ )
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+
32
+ class EigenmodeSim(PalaceSimMixin, BaseModel):
33
+ """Eigenmode simulation for finding resonant frequencies.
34
+
35
+ This class configures and runs eigenmode simulations to find
36
+ resonant frequencies and mode shapes of structures.
37
+
38
+ Example:
39
+ >>> from gsim.palace import EigenmodeSim
40
+ >>>
41
+ >>> sim = EigenmodeSim()
42
+ >>> sim.set_geometry(component)
43
+ >>> sim.set_stack(air_above=300.0)
44
+ >>> sim.add_port("o1", layer="topmetal2", length=5.0)
45
+ >>> sim.set_eigenmode(num_modes=10, target=50e9)
46
+ >>> sim.set_output_dir("./sim")
47
+ >>> sim.mesh(preset="default")
48
+ >>> results = sim.simulate()
49
+
50
+ Attributes:
51
+ geometry: Wrapped gdsfactory Component (from common)
52
+ stack: Layer stack configuration (from common)
53
+ ports: List of single-element port configurations
54
+ cpw_ports: List of CPW (two-element) port configurations
55
+ eigenmode: Eigenmode simulation configuration
56
+ materials: Material property overrides
57
+ numerical: Numerical solver configuration
58
+ """
59
+
60
+ model_config = ConfigDict(
61
+ validate_assignment=True,
62
+ arbitrary_types_allowed=True,
63
+ )
64
+
65
+ # Composed objects (from common)
66
+ geometry: Geometry | None = None
67
+ stack: LayerStack | None = None
68
+
69
+ # Port configurations (eigenmode can have ports for Q-factor calculation)
70
+ ports: list[PortConfig] = Field(default_factory=list)
71
+ cpw_ports: list[CPWPortConfig] = Field(default_factory=list)
72
+
73
+ # Eigenmode simulation config
74
+ eigenmode: EigenmodeConfig = Field(default_factory=EigenmodeConfig)
75
+
76
+ # Material overrides and numerical config
77
+ materials: dict[str, MaterialConfig] = Field(default_factory=dict)
78
+ numerical: NumericalConfig = Field(default_factory=NumericalConfig)
79
+
80
+ # Stack configuration (stored as kwargs until resolved)
81
+ _stack_kwargs: dict[str, Any] = PrivateAttr(default_factory=dict)
82
+
83
+ # Internal state
84
+ _output_dir: Path | None = PrivateAttr(default=None)
85
+ _configured_ports: bool = PrivateAttr(default=False)
86
+
87
+ # -------------------------------------------------------------------------
88
+ # Port methods (Eigenmode can have ports for Q-factor calculation)
89
+ # -------------------------------------------------------------------------
90
+
91
+ def add_port(
92
+ self,
93
+ name: str,
94
+ *,
95
+ layer: str | None = None,
96
+ from_layer: str | None = None,
97
+ to_layer: str | None = None,
98
+ length: float | None = None,
99
+ impedance: float = 50.0,
100
+ excited: bool = True,
101
+ geometry: Literal["inplane", "via"] = "inplane",
102
+ ) -> None:
103
+ """Add a single-element lumped port.
104
+
105
+ Args:
106
+ name: Port name (must match component port name)
107
+ layer: Target layer for inplane ports
108
+ from_layer: Bottom layer for via ports
109
+ to_layer: Top layer for via ports
110
+ length: Port extent along direction (um)
111
+ impedance: Port impedance (Ohms)
112
+ excited: Whether this port is excited
113
+ geometry: Port geometry type ("inplane" or "via")
114
+
115
+ Example:
116
+ >>> sim.add_port("o1", layer="topmetal2", length=5.0)
117
+ """
118
+ self.ports = [p for p in self.ports if p.name != name]
119
+ self.ports.append(
120
+ PortConfig(
121
+ name=name,
122
+ layer=layer,
123
+ from_layer=from_layer,
124
+ to_layer=to_layer,
125
+ length=length,
126
+ impedance=impedance,
127
+ excited=excited,
128
+ geometry=geometry,
129
+ )
130
+ )
131
+
132
+ def add_cpw_port(
133
+ self,
134
+ upper: str,
135
+ lower: str,
136
+ *,
137
+ layer: str,
138
+ length: float,
139
+ impedance: float = 50.0,
140
+ excited: bool = True,
141
+ name: str | None = None,
142
+ ) -> None:
143
+ """Add a coplanar waveguide (CPW) port.
144
+
145
+ Args:
146
+ upper: Name of the upper gap port on the component
147
+ lower: Name of the lower gap port on the component
148
+ layer: Target conductor layer
149
+ length: Port extent along direction (um)
150
+ impedance: Port impedance (Ohms)
151
+ excited: Whether this port is excited
152
+ name: Optional name for the CPW port
153
+
154
+ Example:
155
+ >>> sim.add_cpw_port("P2", "P1", layer="topmetal2", length=5.0)
156
+ """
157
+ self.cpw_ports = [
158
+ p for p in self.cpw_ports if not (p.upper == upper and p.lower == lower)
159
+ ]
160
+ self.cpw_ports.append(
161
+ CPWPortConfig(
162
+ upper=upper,
163
+ lower=lower,
164
+ layer=layer,
165
+ length=length,
166
+ impedance=impedance,
167
+ excited=excited,
168
+ name=name,
169
+ )
170
+ )
171
+
172
+ # -------------------------------------------------------------------------
173
+ # Eigenmode configuration
174
+ # -------------------------------------------------------------------------
175
+
176
+ def set_eigenmode(
177
+ self,
178
+ *,
179
+ num_modes: int = 10,
180
+ target: float | None = None,
181
+ tolerance: float = 1e-6,
182
+ ) -> None:
183
+ """Configure eigenmode simulation.
184
+
185
+ Args:
186
+ num_modes: Number of modes to find
187
+ target: Target frequency in Hz for mode search
188
+ tolerance: Eigenvalue solver tolerance
189
+
190
+ Example:
191
+ >>> sim.set_eigenmode(num_modes=10, target=50e9)
192
+ """
193
+ self.eigenmode = EigenmodeConfig(
194
+ num_modes=num_modes,
195
+ target=target,
196
+ tolerance=tolerance,
197
+ )
198
+
199
+ # -------------------------------------------------------------------------
200
+ # Validation
201
+ # -------------------------------------------------------------------------
202
+
203
+ def validate_config(self) -> ValidationResult:
204
+ """Validate the simulation configuration.
205
+
206
+ Returns:
207
+ ValidationResult with validation status and messages
208
+ """
209
+ errors = []
210
+ warnings_list = []
211
+
212
+ # Check geometry
213
+ if self.geometry is None:
214
+ errors.append("No component set. Call set_geometry(component) first.")
215
+
216
+ # Check stack
217
+ if self.stack is None and not self._stack_kwargs:
218
+ warnings_list.append(
219
+ "No stack configured. Will use active PDK with defaults."
220
+ )
221
+
222
+ # Eigenmode simulations may not require ports
223
+ if not self.ports and not self.cpw_ports:
224
+ warnings_list.append(
225
+ "No ports configured. Eigenmode finds all modes without port loading."
226
+ )
227
+
228
+ # Validate port configurations
229
+ for port in self.ports:
230
+ if port.geometry == "inplane" and port.layer is None:
231
+ errors.append(f"Port '{port.name}': inplane ports require 'layer'")
232
+ if port.geometry == "via" and (
233
+ port.from_layer is None or port.to_layer is None
234
+ ):
235
+ errors.append(
236
+ f"Port '{port.name}': via ports require 'from_layer' and 'to_layer'"
237
+ )
238
+
239
+ valid = len(errors) == 0
240
+ return ValidationResult(valid=valid, errors=errors, warnings=warnings_list)
241
+
242
+ # -------------------------------------------------------------------------
243
+ # Internal helpers
244
+ # -------------------------------------------------------------------------
245
+
246
+ def _configure_ports_on_component(self, stack: LayerStack) -> None: # noqa: ARG002
247
+ """Configure ports on the component."""
248
+ from gsim.palace.ports import (
249
+ configure_cpw_port,
250
+ configure_inplane_port,
251
+ configure_via_port,
252
+ )
253
+
254
+ component = self.geometry.component if self.geometry else None
255
+ if component is None:
256
+ raise ValueError("No component set")
257
+
258
+ for port_config in self.ports:
259
+ if port_config.name is None:
260
+ continue
261
+
262
+ gf_port = None
263
+ for p in component.ports:
264
+ if p.name == port_config.name:
265
+ gf_port = p
266
+ break
267
+
268
+ if gf_port is None:
269
+ raise ValueError(
270
+ f"Port '{port_config.name}' not found on component. "
271
+ f"Available: {[p.name for p in component.ports]}"
272
+ )
273
+
274
+ if port_config.geometry == "inplane" and port_config.layer is not None:
275
+ configure_inplane_port(
276
+ gf_port,
277
+ layer=port_config.layer,
278
+ length=port_config.length or gf_port.width,
279
+ impedance=port_config.impedance,
280
+ excited=port_config.excited,
281
+ )
282
+ elif port_config.geometry == "via" and (
283
+ port_config.from_layer is not None and port_config.to_layer is not None
284
+ ):
285
+ configure_via_port(
286
+ gf_port,
287
+ from_layer=port_config.from_layer,
288
+ to_layer=port_config.to_layer,
289
+ impedance=port_config.impedance,
290
+ excited=port_config.excited,
291
+ )
292
+
293
+ for cpw_config in self.cpw_ports:
294
+ port_upper = None
295
+ port_lower = None
296
+ for p in component.ports:
297
+ if p.name == cpw_config.upper:
298
+ port_upper = p
299
+ if p.name == cpw_config.lower:
300
+ port_lower = p
301
+
302
+ if port_upper is None:
303
+ raise ValueError(f"CPW upper port '{cpw_config.upper}' not found.")
304
+ if port_lower is None:
305
+ raise ValueError(f"CPW lower port '{cpw_config.lower}' not found.")
306
+
307
+ configure_cpw_port(
308
+ port_upper=port_upper,
309
+ port_lower=port_lower,
310
+ layer=cpw_config.layer,
311
+ length=cpw_config.length,
312
+ impedance=cpw_config.impedance,
313
+ excited=cpw_config.excited,
314
+ cpw_name=cpw_config.name,
315
+ )
316
+
317
+ self._configured_ports = True
318
+
319
+ def _generate_mesh_internal(
320
+ self,
321
+ output_dir: Path,
322
+ mesh_config: MeshConfig,
323
+ ports: list,
324
+ model_name: str,
325
+ verbose: bool,
326
+ ) -> SimulationResult:
327
+ """Internal mesh generation."""
328
+ from gsim.palace.mesh import MeshConfig as LegacyMeshConfig
329
+ from gsim.palace.mesh import generate_mesh
330
+
331
+ component = self.geometry.component if self.geometry else None
332
+
333
+ legacy_mesh_config = LegacyMeshConfig(
334
+ refined_mesh_size=mesh_config.refined_mesh_size,
335
+ max_mesh_size=mesh_config.max_mesh_size,
336
+ cells_per_wavelength=mesh_config.cells_per_wavelength,
337
+ margin=mesh_config.margin,
338
+ air_above=mesh_config.air_above,
339
+ fmax=mesh_config.fmax,
340
+ show_gui=mesh_config.show_gui,
341
+ preview_only=mesh_config.preview_only,
342
+ )
343
+
344
+ stack = self._resolve_stack()
345
+
346
+ if verbose:
347
+ logger.info("Generating mesh in %s", output_dir)
348
+
349
+ mesh_result = generate_mesh(
350
+ component=component,
351
+ stack=stack,
352
+ ports=ports,
353
+ output_dir=output_dir,
354
+ config=legacy_mesh_config,
355
+ model_name=model_name,
356
+ driven_config=None, # Eigenmode doesn't use driven config
357
+ )
358
+
359
+ return SimulationResult(
360
+ mesh_path=mesh_result.mesh_path,
361
+ output_dir=output_dir,
362
+ config_path=mesh_result.config_path,
363
+ port_info=mesh_result.port_info,
364
+ mesh_stats=mesh_result.mesh_stats,
365
+ )
366
+
367
+ def _get_ports_for_preview(self, stack: LayerStack) -> list:
368
+ """Get ports for preview."""
369
+ from gsim.palace.ports import extract_ports
370
+
371
+ component = self.geometry.component if self.geometry else None
372
+ if self.ports or self.cpw_ports:
373
+ self._configure_ports_on_component(stack)
374
+ return extract_ports(component, stack)
375
+ return []
376
+
377
+ # -------------------------------------------------------------------------
378
+ # Preview
379
+ # -------------------------------------------------------------------------
380
+
381
+ def preview(
382
+ self,
383
+ *,
384
+ preset: Literal["coarse", "default", "fine"] | None = None,
385
+ refined_mesh_size: float | None = None,
386
+ max_mesh_size: float | None = None,
387
+ margin: float | None = None,
388
+ air_above: float | None = None,
389
+ fmax: float | None = None,
390
+ show_gui: bool = True,
391
+ ) -> None:
392
+ """Preview the mesh without running simulation.
393
+
394
+ Args:
395
+ preset: Mesh quality preset ("coarse", "default", "fine")
396
+ refined_mesh_size: Mesh size near conductors (um)
397
+ max_mesh_size: Max mesh size in air/dielectric (um)
398
+ margin: XY margin around design (um)
399
+ air_above: Air above top metal (um)
400
+ fmax: Max frequency for mesh sizing (Hz)
401
+ show_gui: Show gmsh GUI for interactive preview
402
+
403
+ Example:
404
+ >>> sim.preview(preset="fine", show_gui=True)
405
+ """
406
+ from gsim.palace.mesh import MeshConfig as LegacyMeshConfig
407
+ from gsim.palace.mesh import generate_mesh
408
+
409
+ component = self.geometry.component if self.geometry else None
410
+
411
+ validation = self.validate_config()
412
+ if not validation.valid:
413
+ raise ValueError("Invalid configuration:\n" + "\n".join(validation.errors))
414
+
415
+ mesh_config = self._build_mesh_config(
416
+ preset=preset,
417
+ refined_mesh_size=refined_mesh_size,
418
+ max_mesh_size=max_mesh_size,
419
+ margin=margin,
420
+ air_above=air_above,
421
+ fmax=fmax,
422
+ show_gui=show_gui,
423
+ )
424
+
425
+ stack = self._resolve_stack()
426
+ ports = self._get_ports_for_preview(stack)
427
+
428
+ legacy_mesh_config = LegacyMeshConfig(
429
+ refined_mesh_size=mesh_config.refined_mesh_size,
430
+ max_mesh_size=mesh_config.max_mesh_size,
431
+ cells_per_wavelength=mesh_config.cells_per_wavelength,
432
+ margin=mesh_config.margin,
433
+ air_above=mesh_config.air_above,
434
+ fmax=mesh_config.fmax,
435
+ show_gui=show_gui,
436
+ preview_only=True,
437
+ )
438
+
439
+ with tempfile.TemporaryDirectory() as tmpdir:
440
+ generate_mesh(
441
+ component=component,
442
+ stack=stack,
443
+ ports=ports,
444
+ output_dir=tmpdir,
445
+ config=legacy_mesh_config,
446
+ )
447
+
448
+ # -------------------------------------------------------------------------
449
+ # Mesh generation
450
+ # -------------------------------------------------------------------------
451
+
452
+ def mesh(
453
+ self,
454
+ *,
455
+ preset: Literal["coarse", "default", "fine"] | None = None,
456
+ refined_mesh_size: float | None = None,
457
+ max_mesh_size: float | None = None,
458
+ margin: float | None = None,
459
+ air_above: float | None = None,
460
+ fmax: float | None = None,
461
+ show_gui: bool = False,
462
+ model_name: str = "palace",
463
+ verbose: bool = True,
464
+ ) -> SimulationResult:
465
+ """Generate the mesh for Palace simulation.
466
+
467
+ Requires set_output_dir() to be called first.
468
+
469
+ Args:
470
+ preset: Mesh quality preset ("coarse", "default", "fine")
471
+ refined_mesh_size: Mesh size near conductors (um), overrides preset
472
+ max_mesh_size: Max mesh size in air/dielectric (um), overrides preset
473
+ margin: XY margin around design (um), overrides preset
474
+ air_above: Air above top metal (um), overrides preset
475
+ fmax: Max frequency for mesh sizing (Hz), overrides preset
476
+ show_gui: Show gmsh GUI during meshing
477
+ model_name: Base name for output files
478
+ verbose: Print progress messages
479
+
480
+ Returns:
481
+ SimulationResult with mesh path
482
+
483
+ Raises:
484
+ ValueError: If output_dir not set or configuration is invalid
485
+
486
+ Example:
487
+ >>> sim.set_output_dir("./sim")
488
+ >>> result = sim.mesh(preset="fine")
489
+ >>> print(f"Mesh saved to: {result.mesh_path}")
490
+ """
491
+ from gsim.palace.ports import extract_ports
492
+
493
+ if self._output_dir is None:
494
+ raise ValueError("Output directory not set. Call set_output_dir() first.")
495
+
496
+ component = self.geometry.component if self.geometry else None
497
+
498
+ mesh_config = self._build_mesh_config(
499
+ preset=preset,
500
+ refined_mesh_size=refined_mesh_size,
501
+ max_mesh_size=max_mesh_size,
502
+ margin=margin,
503
+ air_above=air_above,
504
+ fmax=fmax,
505
+ show_gui=show_gui,
506
+ )
507
+
508
+ validation = self.validate_config()
509
+ if not validation.valid:
510
+ raise ValueError("Invalid configuration:\n" + "\n".join(validation.errors))
511
+
512
+ output_dir = self._output_dir
513
+
514
+ stack = self._resolve_stack()
515
+
516
+ palace_ports = []
517
+ if self.ports or self.cpw_ports:
518
+ self._configure_ports_on_component(stack)
519
+ palace_ports = extract_ports(component, stack)
520
+
521
+ return self._generate_mesh_internal(
522
+ output_dir=output_dir,
523
+ mesh_config=mesh_config,
524
+ ports=palace_ports,
525
+ model_name=model_name,
526
+ verbose=verbose,
527
+ )
528
+
529
+ # -------------------------------------------------------------------------
530
+ # Simulation
531
+ # -------------------------------------------------------------------------
532
+
533
+ def simulate(
534
+ self,
535
+ output_dir: str | Path | None = None,
536
+ *,
537
+ verbose: bool = True,
538
+ ) -> dict[str, Path]:
539
+ """Run eigenmode simulation on GDSFactory+ cloud.
540
+
541
+ Args:
542
+ output_dir: Directory containing mesh files
543
+ verbose: Print progress messages
544
+
545
+ Returns:
546
+ Dict mapping result filenames to local paths
547
+
548
+ Raises:
549
+ NotImplementedError: Eigenmode is not yet fully implemented
550
+ """
551
+ raise NotImplementedError(
552
+ "Eigenmode simulation is not yet fully implemented on cloud. "
553
+ "Use DrivenSim for S-parameter extraction."
554
+ )
555
+
556
+
557
+ __all__ = ["EigenmodeSim"]