gsim 0.0.0__py3-none-any.whl → 0.0.2__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.
gsim/palace/driven.py ADDED
@@ -0,0 +1,1004 @@
1
+ """Driven simulation class for frequency-domain S-parameter extraction.
2
+
3
+ This module provides the DrivenSim class for running frequency-sweep
4
+ simulations to extract S-parameters.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ import tempfile
11
+ import warnings
12
+ from pathlib import Path
13
+
14
+ logger = logging.getLogger(__name__)
15
+ from typing import TYPE_CHECKING, Any, Literal
16
+
17
+ from pydantic import BaseModel, ConfigDict, Field, PrivateAttr
18
+
19
+ from gsim.common import Geometry, LayerStack
20
+ from gsim.palace.base import PalaceSimMixin
21
+ from gsim.palace.models import (
22
+ CPWPortConfig,
23
+ DrivenConfig,
24
+ MaterialConfig,
25
+ MeshConfig,
26
+ NumericalConfig,
27
+ PortConfig,
28
+ SimulationResult,
29
+ ValidationResult,
30
+ )
31
+
32
+ if TYPE_CHECKING:
33
+ from gdsfactory.component import Component
34
+
35
+
36
+ class DrivenSim(PalaceSimMixin, BaseModel):
37
+ """Frequency-domain driven simulation for S-parameter extraction.
38
+
39
+ This class configures and runs driven simulations that sweep through
40
+ frequencies to compute S-parameters. Uses composition (no inheritance)
41
+ with shared Geometry and Stack components from gsim.common.
42
+
43
+ Example:
44
+ >>> from gsim.palace import DrivenSim
45
+ >>>
46
+ >>> sim = DrivenSim()
47
+ >>> sim.set_geometry(component)
48
+ >>> sim.set_stack(air_above=300.0)
49
+ >>> sim.add_cpw_port("P2", "P1", layer="topmetal2", length=5.0)
50
+ >>> sim.add_cpw_port("P3", "P4", layer="topmetal2", length=5.0)
51
+ >>> sim.set_driven(fmin=1e9, fmax=100e9, num_points=40)
52
+ >>> sim.mesh("./sim", preset="default")
53
+ >>> results = sim.simulate()
54
+
55
+ Attributes:
56
+ geometry: Wrapped gdsfactory Component (from common)
57
+ stack: Layer stack configuration (from common)
58
+ ports: List of single-element port configurations
59
+ cpw_ports: List of CPW (two-element) port configurations
60
+ driven: Driven simulation configuration (frequencies, etc.)
61
+ mesh: Mesh configuration
62
+ materials: Material property overrides
63
+ numerical: Numerical solver configuration
64
+ """
65
+
66
+ model_config = ConfigDict(
67
+ validate_assignment=True,
68
+ arbitrary_types_allowed=True,
69
+ )
70
+
71
+ # Composed objects (from common)
72
+ geometry: Geometry | None = None
73
+ stack: LayerStack | None = None
74
+
75
+ # Port configurations
76
+ ports: list[PortConfig] = Field(default_factory=list)
77
+ cpw_ports: list[CPWPortConfig] = Field(default_factory=list)
78
+
79
+ # Driven simulation config
80
+ driven: DrivenConfig = Field(default_factory=DrivenConfig)
81
+
82
+ # Mesh config
83
+ mesh_config: MeshConfig = Field(default_factory=MeshConfig.default)
84
+
85
+ # Material overrides and numerical config
86
+ materials: dict[str, MaterialConfig] = Field(default_factory=dict)
87
+ numerical: NumericalConfig = Field(default_factory=NumericalConfig)
88
+
89
+ # Stack configuration (stored as kwargs until resolved)
90
+ _stack_kwargs: dict[str, Any] = PrivateAttr(default_factory=dict)
91
+
92
+ # Internal state
93
+ _output_dir: Path | None = PrivateAttr(default=None)
94
+ _configured_ports: bool = PrivateAttr(default=False)
95
+ _last_mesh_result: Any = PrivateAttr(default=None)
96
+ _last_ports: list = PrivateAttr(default_factory=list)
97
+
98
+ # -------------------------------------------------------------------------
99
+ # Output directory
100
+ # -------------------------------------------------------------------------
101
+
102
+ def set_output_dir(self, path: str | Path) -> None:
103
+ """Set the output directory for mesh and config files.
104
+
105
+ Args:
106
+ path: Directory path for output files
107
+
108
+ Example:
109
+ >>> sim.set_output_dir("./palace-sim")
110
+ """
111
+ self._output_dir = Path(path)
112
+ self._output_dir.mkdir(parents=True, exist_ok=True)
113
+
114
+ @property
115
+ def output_dir(self) -> Path | None:
116
+ """Get the current output directory."""
117
+ return self._output_dir
118
+
119
+ # -------------------------------------------------------------------------
120
+ # Geometry methods
121
+ # -------------------------------------------------------------------------
122
+
123
+ def set_geometry(self, component: Component) -> None:
124
+ """Set the gdsfactory component for simulation.
125
+
126
+ Args:
127
+ component: gdsfactory Component to simulate
128
+
129
+ Example:
130
+ >>> sim.set_geometry(my_component)
131
+ """
132
+ self.geometry = Geometry(component=component)
133
+
134
+ @property
135
+ def component(self) -> Component | None:
136
+ """Get the current component (for backward compatibility)."""
137
+ return self.geometry.component if self.geometry else None
138
+
139
+ # Backward compatibility alias
140
+ @property
141
+ def _component(self) -> Component | None:
142
+ """Internal component access (backward compatibility)."""
143
+ return self.component
144
+
145
+ # -------------------------------------------------------------------------
146
+ # Stack methods
147
+ # -------------------------------------------------------------------------
148
+
149
+ def set_stack(
150
+ self,
151
+ *,
152
+ yaml_path: str | Path | None = None,
153
+ air_above: float = 200.0,
154
+ substrate_thickness: float = 2.0,
155
+ include_substrate: bool = False,
156
+ **kwargs,
157
+ ) -> None:
158
+ """Configure the layer stack.
159
+
160
+ If yaml_path is provided, loads stack from YAML file.
161
+ Otherwise, extracts from active PDK with given parameters.
162
+
163
+ Args:
164
+ yaml_path: Path to custom YAML stack file
165
+ air_above: Air box height above top metal in um
166
+ substrate_thickness: Thickness below z=0 in um
167
+ include_substrate: Include lossy silicon substrate
168
+ **kwargs: Additional args passed to extract_layer_stack
169
+
170
+ Example:
171
+ >>> sim.set_stack(air_above=300.0, substrate_thickness=2.0)
172
+ """
173
+ self._stack_kwargs = {
174
+ "yaml_path": yaml_path,
175
+ "air_above": air_above,
176
+ "substrate_thickness": substrate_thickness,
177
+ "include_substrate": include_substrate,
178
+ **kwargs,
179
+ }
180
+ # Stack will be resolved lazily during mesh() or simulate()
181
+ self.stack = None
182
+
183
+ # -------------------------------------------------------------------------
184
+ # Port methods
185
+ # -------------------------------------------------------------------------
186
+
187
+ def add_port(
188
+ self,
189
+ name: str,
190
+ *,
191
+ layer: str | None = None,
192
+ from_layer: str | None = None,
193
+ to_layer: str | None = None,
194
+ length: float | None = None,
195
+ impedance: float = 50.0,
196
+ excited: bool = True,
197
+ geometry: Literal["inplane", "via"] = "inplane",
198
+ ) -> None:
199
+ """Add a single-element lumped port.
200
+
201
+ Args:
202
+ name: Port name (must match component port name)
203
+ layer: Target layer for inplane ports
204
+ from_layer: Bottom layer for via ports
205
+ to_layer: Top layer for via ports
206
+ length: Port extent along direction (um)
207
+ impedance: Port impedance (Ohms)
208
+ excited: Whether this port is excited
209
+ geometry: Port geometry type ("inplane" or "via")
210
+
211
+ Example:
212
+ >>> sim.add_port("o1", layer="topmetal2", length=5.0)
213
+ >>> sim.add_port("feed", from_layer="metal1", to_layer="topmetal2", geometry="via")
214
+ """
215
+ # Remove existing config for this port if any
216
+ self.ports = [p for p in self.ports if p.name != name]
217
+
218
+ self.ports.append(
219
+ PortConfig(
220
+ name=name,
221
+ layer=layer,
222
+ from_layer=from_layer,
223
+ to_layer=to_layer,
224
+ length=length,
225
+ impedance=impedance,
226
+ excited=excited,
227
+ geometry=geometry,
228
+ )
229
+ )
230
+
231
+ def add_cpw_port(
232
+ self,
233
+ upper: str,
234
+ lower: str,
235
+ *,
236
+ layer: str,
237
+ length: float,
238
+ impedance: float = 50.0,
239
+ excited: bool = True,
240
+ name: str | None = None,
241
+ ) -> None:
242
+ """Add a coplanar waveguide (CPW) port.
243
+
244
+ CPW ports consist of two elements (upper and lower gaps) that are
245
+ excited with opposite E-field directions to create the CPW mode.
246
+
247
+ Args:
248
+ upper: Name of the upper gap port on the component
249
+ lower: Name of the lower gap port on the component
250
+ layer: Target conductor layer (e.g., "topmetal2")
251
+ length: Port extent along direction (um)
252
+ impedance: Port impedance (Ohms)
253
+ excited: Whether this port is excited
254
+ name: Optional name for the CPW port (default: "cpw_{lower}")
255
+
256
+ Example:
257
+ >>> sim.add_cpw_port("P2", "P1", layer="topmetal2", length=5.0)
258
+ """
259
+ # Remove existing CPW port with same elements if any
260
+ self.cpw_ports = [
261
+ p for p in self.cpw_ports if not (p.upper == upper and p.lower == lower)
262
+ ]
263
+
264
+ self.cpw_ports.append(
265
+ CPWPortConfig(
266
+ upper=upper,
267
+ lower=lower,
268
+ layer=layer,
269
+ length=length,
270
+ impedance=impedance,
271
+ excited=excited,
272
+ name=name,
273
+ )
274
+ )
275
+
276
+ # -------------------------------------------------------------------------
277
+ # Driven configuration
278
+ # -------------------------------------------------------------------------
279
+
280
+ def set_driven(
281
+ self,
282
+ *,
283
+ fmin: float = 1e9,
284
+ fmax: float = 100e9,
285
+ num_points: int = 40,
286
+ scale: Literal["linear", "log"] = "linear",
287
+ adaptive_tol: float = 0.02,
288
+ adaptive_max_samples: int = 20,
289
+ compute_s_params: bool = True,
290
+ reference_impedance: float = 50.0,
291
+ excitation_port: str | None = None,
292
+ ) -> None:
293
+ """Configure driven (frequency sweep) simulation.
294
+
295
+ Args:
296
+ fmin: Minimum frequency in Hz
297
+ fmax: Maximum frequency in Hz
298
+ num_points: Number of frequency points
299
+ scale: "linear" or "log" frequency spacing
300
+ adaptive_tol: Adaptive frequency tolerance (0 disables adaptive)
301
+ adaptive_max_samples: Max samples for adaptive refinement
302
+ compute_s_params: Compute S-parameters
303
+ reference_impedance: Reference impedance for S-params (Ohms)
304
+ excitation_port: Port to excite (None = first port)
305
+
306
+ Example:
307
+ >>> sim.set_driven(fmin=1e9, fmax=100e9, num_points=40)
308
+ """
309
+ self.driven = DrivenConfig(
310
+ fmin=fmin,
311
+ fmax=fmax,
312
+ num_points=num_points,
313
+ scale=scale,
314
+ adaptive_tol=adaptive_tol,
315
+ adaptive_max_samples=adaptive_max_samples,
316
+ compute_s_params=compute_s_params,
317
+ reference_impedance=reference_impedance,
318
+ excitation_port=excitation_port,
319
+ )
320
+
321
+ # -------------------------------------------------------------------------
322
+ # Material methods
323
+ # -------------------------------------------------------------------------
324
+
325
+ def set_material(
326
+ self,
327
+ name: str,
328
+ *,
329
+ type: Literal["conductor", "dielectric", "semiconductor"] | None = None,
330
+ conductivity: float | None = None,
331
+ permittivity: float | None = None,
332
+ loss_tangent: float | None = None,
333
+ ) -> None:
334
+ """Override or add material properties.
335
+
336
+ Args:
337
+ name: Material name
338
+ type: Material type (conductor, dielectric, semiconductor)
339
+ conductivity: Conductivity in S/m (for conductors)
340
+ permittivity: Relative permittivity (for dielectrics)
341
+ loss_tangent: Dielectric loss tangent
342
+
343
+ Example:
344
+ >>> sim.set_material("aluminum", type="conductor", conductivity=3.8e7)
345
+ >>> sim.set_material("sio2", type="dielectric", permittivity=3.9)
346
+ """
347
+ # Determine type if not provided
348
+ if type is None:
349
+ if conductivity is not None and conductivity > 1e4:
350
+ type = "conductor"
351
+ elif permittivity is not None:
352
+ type = "dielectric"
353
+ else:
354
+ type = "dielectric"
355
+
356
+ self.materials[name] = MaterialConfig(
357
+ type=type,
358
+ conductivity=conductivity,
359
+ permittivity=permittivity,
360
+ loss_tangent=loss_tangent,
361
+ )
362
+
363
+ def set_numerical(
364
+ self,
365
+ *,
366
+ order: int = 2,
367
+ tolerance: float = 1e-6,
368
+ max_iterations: int = 400,
369
+ solver_type: Literal["Default", "SuperLU", "STRUMPACK", "MUMPS"] = "Default",
370
+ preconditioner: Literal["Default", "AMS", "BoomerAMG"] = "Default",
371
+ device: Literal["CPU", "GPU"] = "CPU",
372
+ num_processors: int | None = None,
373
+ ) -> None:
374
+ """Configure numerical solver parameters.
375
+
376
+ Args:
377
+ order: Finite element order (1-4)
378
+ tolerance: Linear solver tolerance
379
+ max_iterations: Maximum solver iterations
380
+ solver_type: Linear solver type
381
+ preconditioner: Preconditioner type
382
+ device: Compute device (CPU or GPU)
383
+ num_processors: Number of processors (None = auto)
384
+
385
+ Example:
386
+ >>> sim.set_numerical(order=3, tolerance=1e-8)
387
+ """
388
+ self.numerical = NumericalConfig(
389
+ order=order,
390
+ tolerance=tolerance,
391
+ max_iterations=max_iterations,
392
+ solver_type=solver_type,
393
+ preconditioner=preconditioner,
394
+ device=device,
395
+ num_processors=num_processors,
396
+ )
397
+
398
+ # -------------------------------------------------------------------------
399
+ # Validation
400
+ # -------------------------------------------------------------------------
401
+
402
+ def validate(self) -> ValidationResult:
403
+ """Validate the simulation configuration.
404
+
405
+ Returns:
406
+ ValidationResult with validation status and messages
407
+ """
408
+ errors = []
409
+ warnings_list = []
410
+
411
+ # Check geometry
412
+ if self.geometry is None:
413
+ errors.append("No component set. Call set_geometry(component) first.")
414
+
415
+ # Check stack
416
+ if self.stack is None and not self._stack_kwargs:
417
+ warnings_list.append(
418
+ "No stack configured. Will use active PDK with defaults."
419
+ )
420
+
421
+ # Check ports
422
+ has_ports = bool(self.ports) or bool(self.cpw_ports)
423
+ if not has_ports:
424
+ warnings_list.append(
425
+ "No ports configured. Call add_port() or add_cpw_port()."
426
+ )
427
+ else:
428
+ # Validate port configurations
429
+ for port in self.ports:
430
+ if port.geometry == "inplane" and port.layer is None:
431
+ errors.append(
432
+ f"Port '{port.name}': inplane ports require 'layer'"
433
+ )
434
+ if port.geometry == "via":
435
+ if port.from_layer is None or port.to_layer is None:
436
+ errors.append(
437
+ f"Port '{port.name}': via ports require "
438
+ "'from_layer' and 'to_layer'"
439
+ )
440
+
441
+ # Validate CPW ports
442
+ for cpw in self.cpw_ports:
443
+ if not cpw.layer:
444
+ errors.append(
445
+ f"CPW port ({cpw.upper}, {cpw.lower}): 'layer' is required"
446
+ )
447
+
448
+ # Validate excitation port if specified
449
+ if self.driven.excitation_port is not None:
450
+ port_names = [p.name for p in self.ports]
451
+ cpw_names = [cpw.effective_name for cpw in self.cpw_ports]
452
+ all_port_names = port_names + cpw_names
453
+ if self.driven.excitation_port not in all_port_names:
454
+ errors.append(
455
+ f"Excitation port '{self.driven.excitation_port}' not found. "
456
+ f"Available: {all_port_names}"
457
+ )
458
+
459
+ valid = len(errors) == 0
460
+ return ValidationResult(valid=valid, errors=errors, warnings=warnings_list)
461
+
462
+ # -------------------------------------------------------------------------
463
+ # Internal helpers
464
+ # -------------------------------------------------------------------------
465
+
466
+ def _resolve_stack(self) -> LayerStack:
467
+ """Resolve the layer stack from PDK or YAML.
468
+
469
+ Returns:
470
+ Legacy LayerStack object for mesh generation
471
+ """
472
+ from gsim.common.stack import get_stack
473
+
474
+ yaml_path = self._stack_kwargs.pop("yaml_path", None)
475
+ legacy_stack = get_stack(yaml_path=yaml_path, **self._stack_kwargs)
476
+
477
+ # Restore yaml_path for potential re-resolution
478
+ self._stack_kwargs["yaml_path"] = yaml_path
479
+
480
+ # Apply material overrides
481
+ for name, props in self.materials.items():
482
+ legacy_stack.materials[name] = props.to_dict()
483
+
484
+ # Store the LayerStack
485
+ self.stack = legacy_stack
486
+
487
+ return legacy_stack
488
+
489
+ def _configure_ports_on_component(self, stack: LayerStack) -> None:
490
+ """Configure ports on the component using legacy functions."""
491
+ from gsim.palace.ports import (
492
+ configure_cpw_port,
493
+ configure_inplane_port,
494
+ configure_via_port,
495
+ )
496
+
497
+ component = self.geometry.component if self.geometry else None
498
+ if component is None:
499
+ raise ValueError("No component set")
500
+
501
+ # Configure regular ports
502
+ for port_config in self.ports:
503
+ if port_config.name is None:
504
+ continue
505
+
506
+ # Find matching gdsfactory port
507
+ gf_port = None
508
+ for p in component.ports:
509
+ if p.name == port_config.name:
510
+ gf_port = p
511
+ break
512
+
513
+ if gf_port is None:
514
+ raise ValueError(
515
+ f"Port '{port_config.name}' not found on component. "
516
+ f"Available ports: {[p.name for p in component.ports]}"
517
+ )
518
+
519
+ if port_config.geometry == "inplane":
520
+ configure_inplane_port(
521
+ gf_port,
522
+ layer=port_config.layer,
523
+ length=port_config.length or gf_port.width,
524
+ impedance=port_config.impedance,
525
+ excited=port_config.excited,
526
+ )
527
+ elif port_config.geometry == "via":
528
+ configure_via_port(
529
+ gf_port,
530
+ from_layer=port_config.from_layer,
531
+ to_layer=port_config.to_layer,
532
+ impedance=port_config.impedance,
533
+ excited=port_config.excited,
534
+ )
535
+
536
+ # Configure CPW ports
537
+ for cpw_config in self.cpw_ports:
538
+ # Find upper port
539
+ port_upper = None
540
+ for p in component.ports:
541
+ if p.name == cpw_config.upper:
542
+ port_upper = p
543
+ break
544
+ if port_upper is None:
545
+ raise ValueError(
546
+ f"CPW upper port '{cpw_config.upper}' not found. "
547
+ f"Available: {[p.name for p in component.ports]}"
548
+ )
549
+
550
+ # Find lower port
551
+ port_lower = None
552
+ for p in component.ports:
553
+ if p.name == cpw_config.lower:
554
+ port_lower = p
555
+ break
556
+ if port_lower is None:
557
+ raise ValueError(
558
+ f"CPW lower port '{cpw_config.lower}' not found. "
559
+ f"Available: {[p.name for p in component.ports]}"
560
+ )
561
+
562
+ configure_cpw_port(
563
+ port_upper=port_upper,
564
+ port_lower=port_lower,
565
+ layer=cpw_config.layer,
566
+ length=cpw_config.length,
567
+ impedance=cpw_config.impedance,
568
+ excited=cpw_config.excited,
569
+ cpw_name=cpw_config.name,
570
+ )
571
+
572
+ self._configured_ports = True
573
+
574
+ def _build_mesh_config(
575
+ self,
576
+ preset: Literal["coarse", "default", "fine"] | None,
577
+ refined_mesh_size: float | None,
578
+ max_mesh_size: float | None,
579
+ margin: float | None,
580
+ air_above: float | None,
581
+ fmax: float | None,
582
+ show_gui: bool,
583
+ ) -> MeshConfig:
584
+ """Build mesh config from preset with optional overrides."""
585
+ # Build mesh config from preset
586
+ if preset == "coarse":
587
+ mesh_config = MeshConfig.coarse()
588
+ elif preset == "fine":
589
+ mesh_config = MeshConfig.fine()
590
+ else:
591
+ mesh_config = MeshConfig.default()
592
+
593
+ # Track overrides for warning
594
+ overrides = []
595
+ if preset is not None:
596
+ if refined_mesh_size is not None:
597
+ overrides.append(f"refined_mesh_size={refined_mesh_size}")
598
+ if max_mesh_size is not None:
599
+ overrides.append(f"max_mesh_size={max_mesh_size}")
600
+ if margin is not None:
601
+ overrides.append(f"margin={margin}")
602
+ if air_above is not None:
603
+ overrides.append(f"air_above={air_above}")
604
+ if fmax is not None:
605
+ overrides.append(f"fmax={fmax}")
606
+
607
+ if overrides:
608
+ warnings.warn(
609
+ f"Preset '{preset}' values overridden by: {', '.join(overrides)}",
610
+ stacklevel=4,
611
+ )
612
+
613
+ # Apply overrides
614
+ if refined_mesh_size is not None:
615
+ mesh_config.refined_mesh_size = refined_mesh_size
616
+ if max_mesh_size is not None:
617
+ mesh_config.max_mesh_size = max_mesh_size
618
+ if margin is not None:
619
+ mesh_config.margin = margin
620
+ if air_above is not None:
621
+ mesh_config.air_above = air_above
622
+ if fmax is not None:
623
+ mesh_config.fmax = fmax
624
+ mesh_config.show_gui = show_gui
625
+
626
+ return mesh_config
627
+
628
+ def _generate_mesh_internal(
629
+ self,
630
+ output_dir: Path,
631
+ mesh_config: MeshConfig,
632
+ ports: list,
633
+ driven_config: Any,
634
+ model_name: str,
635
+ verbose: bool,
636
+ write_config: bool = True,
637
+ ) -> SimulationResult:
638
+ """Internal mesh generation."""
639
+ from gsim.palace.mesh import MeshConfig as LegacyMeshConfig
640
+ from gsim.palace.mesh import generate_mesh
641
+
642
+ component = self.geometry.component if self.geometry else None
643
+
644
+ # Get effective fmax from driven config if mesh doesn't specify
645
+ effective_fmax = mesh_config.fmax
646
+ if driven_config is not None and mesh_config.fmax == 100e9:
647
+ effective_fmax = driven_config.fmax
648
+
649
+ legacy_mesh_config = LegacyMeshConfig(
650
+ refined_mesh_size=mesh_config.refined_mesh_size,
651
+ max_mesh_size=mesh_config.max_mesh_size,
652
+ cells_per_wavelength=mesh_config.cells_per_wavelength,
653
+ margin=mesh_config.margin,
654
+ air_above=mesh_config.air_above,
655
+ fmax=effective_fmax,
656
+ show_gui=mesh_config.show_gui,
657
+ preview_only=mesh_config.preview_only,
658
+ )
659
+
660
+ # Resolve stack
661
+ stack = self._resolve_stack()
662
+
663
+ if verbose:
664
+ logger.info("Generating mesh in %s", output_dir)
665
+
666
+ mesh_result = generate_mesh(
667
+ component=component,
668
+ stack=stack,
669
+ ports=ports,
670
+ output_dir=output_dir,
671
+ config=legacy_mesh_config,
672
+ model_name=model_name,
673
+ driven_config=driven_config,
674
+ write_config=write_config,
675
+ )
676
+
677
+ # Store mesh_result for deferred config generation
678
+ self._last_mesh_result = mesh_result
679
+ self._last_ports = ports
680
+
681
+ return SimulationResult(
682
+ mesh_path=mesh_result.mesh_path,
683
+ output_dir=output_dir,
684
+ config_path=mesh_result.config_path,
685
+ port_info=mesh_result.port_info,
686
+ mesh_stats=mesh_result.mesh_stats,
687
+ )
688
+
689
+ def _get_ports_for_preview(self, stack: LayerStack) -> list:
690
+ """Get ports for preview."""
691
+ from gsim.palace.ports import extract_ports
692
+
693
+ component = self.geometry.component if self.geometry else None
694
+ self._configure_ports_on_component(stack)
695
+ return extract_ports(component, stack)
696
+
697
+ # -------------------------------------------------------------------------
698
+ # Preview
699
+ # -------------------------------------------------------------------------
700
+
701
+ def preview(
702
+ self,
703
+ *,
704
+ preset: Literal["coarse", "default", "fine"] | None = None,
705
+ refined_mesh_size: float | None = None,
706
+ max_mesh_size: float | None = None,
707
+ margin: float | None = None,
708
+ air_above: float | None = None,
709
+ fmax: float | None = None,
710
+ show_gui: bool = True,
711
+ ) -> None:
712
+ """Preview the mesh without running simulation.
713
+
714
+ Opens the gmsh GUI to visualize the mesh interactively.
715
+
716
+ Args:
717
+ preset: Mesh quality preset ("coarse", "default", "fine")
718
+ refined_mesh_size: Mesh size near conductors (um)
719
+ max_mesh_size: Max mesh size in air/dielectric (um)
720
+ margin: XY margin around design (um)
721
+ air_above: Air above top metal (um)
722
+ fmax: Max frequency for mesh sizing (Hz)
723
+ show_gui: Show gmsh GUI for interactive preview
724
+
725
+ Example:
726
+ >>> sim.preview(preset="fine", show_gui=True)
727
+ """
728
+ from gsim.palace.mesh import MeshConfig as LegacyMeshConfig
729
+ from gsim.palace.mesh import generate_mesh
730
+
731
+ component = self.geometry.component if self.geometry else None
732
+
733
+ # Validate configuration
734
+ validation = self.validate()
735
+ if not validation.valid:
736
+ raise ValueError(
737
+ f"Invalid configuration:\n" + "\n".join(validation.errors)
738
+ )
739
+
740
+ # Build mesh config
741
+ mesh_config = self._build_mesh_config(
742
+ preset=preset,
743
+ refined_mesh_size=refined_mesh_size,
744
+ max_mesh_size=max_mesh_size,
745
+ margin=margin,
746
+ air_above=air_above,
747
+ fmax=fmax,
748
+ show_gui=show_gui,
749
+ )
750
+
751
+ # Resolve stack
752
+ stack = self._resolve_stack()
753
+
754
+ # Get ports
755
+ ports = self._get_ports_for_preview(stack)
756
+
757
+ # Build legacy mesh config with preview mode
758
+ legacy_mesh_config = LegacyMeshConfig(
759
+ refined_mesh_size=mesh_config.refined_mesh_size,
760
+ max_mesh_size=mesh_config.max_mesh_size,
761
+ cells_per_wavelength=mesh_config.cells_per_wavelength,
762
+ margin=mesh_config.margin,
763
+ air_above=mesh_config.air_above,
764
+ fmax=mesh_config.fmax,
765
+ show_gui=show_gui,
766
+ preview_only=True,
767
+ )
768
+
769
+ # Generate mesh in temp directory
770
+ with tempfile.TemporaryDirectory() as tmpdir:
771
+ generate_mesh(
772
+ component=component,
773
+ stack=stack,
774
+ ports=ports,
775
+ output_dir=tmpdir,
776
+ config=legacy_mesh_config,
777
+ )
778
+
779
+ # -------------------------------------------------------------------------
780
+ # Convenience methods
781
+ # -------------------------------------------------------------------------
782
+
783
+ def show_stack(self) -> None:
784
+ """Print the layer stack table.
785
+
786
+ Example:
787
+ >>> sim.show_stack()
788
+ """
789
+ from gsim.common.stack import print_stack_table
790
+
791
+ if self.stack is None:
792
+ self._resolve_stack()
793
+
794
+ if self.stack is not None:
795
+ print_stack_table(self.stack)
796
+
797
+ def plot_stack(self) -> None:
798
+ """Plot the layer stack visualization.
799
+
800
+ Example:
801
+ >>> sim.plot_stack()
802
+ """
803
+ from gsim.common.stack import plot_stack
804
+
805
+ if self.stack is None:
806
+ self._resolve_stack()
807
+
808
+ if self.stack is not None:
809
+ plot_stack(self.stack)
810
+
811
+ # -------------------------------------------------------------------------
812
+ # Mesh generation
813
+ # -------------------------------------------------------------------------
814
+
815
+ def mesh(
816
+ self,
817
+ *,
818
+ preset: Literal["coarse", "default", "fine"] | None = None,
819
+ refined_mesh_size: float | None = None,
820
+ max_mesh_size: float | None = None,
821
+ margin: float | None = None,
822
+ air_above: float | None = None,
823
+ fmax: float | None = None,
824
+ show_gui: bool = False,
825
+ model_name: str = "palace",
826
+ verbose: bool = True,
827
+ ) -> SimulationResult:
828
+ """Generate the mesh for Palace simulation.
829
+
830
+ Only generates the mesh file (palace.msh). Config is generated
831
+ separately with write_config().
832
+
833
+ Requires set_output_dir() to be called first.
834
+
835
+ Args:
836
+ preset: Mesh quality preset ("coarse", "default", "fine")
837
+ refined_mesh_size: Mesh size near conductors (um), overrides preset
838
+ max_mesh_size: Max mesh size in air/dielectric (um), overrides preset
839
+ margin: XY margin around design (um), overrides preset
840
+ air_above: Air above top metal (um), overrides preset
841
+ fmax: Max frequency for mesh sizing (Hz), overrides preset
842
+ show_gui: Show gmsh GUI during meshing
843
+ model_name: Base name for output files
844
+ verbose: Print progress messages
845
+
846
+ Returns:
847
+ SimulationResult with mesh path
848
+
849
+ Raises:
850
+ ValueError: If output_dir not set or configuration is invalid
851
+
852
+ Example:
853
+ >>> sim.set_output_dir("./sim")
854
+ >>> result = sim.mesh(preset="fine")
855
+ >>> print(f"Mesh saved to: {result.mesh_path}")
856
+ """
857
+ from gsim.palace.ports import extract_ports
858
+
859
+ if self._output_dir is None:
860
+ raise ValueError("Output directory not set. Call set_output_dir() first.")
861
+
862
+ component = self.geometry.component if self.geometry else None
863
+
864
+ # Build mesh config
865
+ mesh_config = self._build_mesh_config(
866
+ preset=preset,
867
+ refined_mesh_size=refined_mesh_size,
868
+ max_mesh_size=max_mesh_size,
869
+ margin=margin,
870
+ air_above=air_above,
871
+ fmax=fmax,
872
+ show_gui=show_gui,
873
+ )
874
+
875
+ # Validate configuration
876
+ validation = self.validate()
877
+ if not validation.valid:
878
+ raise ValueError(
879
+ f"Invalid configuration:\n" + "\n".join(validation.errors)
880
+ )
881
+
882
+ output_dir = self._output_dir
883
+
884
+ # Resolve stack and configure ports
885
+ stack = self._resolve_stack()
886
+ self._configure_ports_on_component(stack)
887
+
888
+ # Extract ports
889
+ palace_ports = extract_ports(component, stack)
890
+
891
+ # Generate mesh (config is written separately by simulate() or write_config())
892
+ return self._generate_mesh_internal(
893
+ output_dir=output_dir,
894
+ mesh_config=mesh_config,
895
+ ports=palace_ports,
896
+ driven_config=self.driven,
897
+ model_name=model_name,
898
+ verbose=verbose,
899
+ write_config=False,
900
+ )
901
+
902
+ def write_config(self) -> Path:
903
+ """Write Palace config.json after mesh generation.
904
+
905
+ Use this when mesh() was called with write_config=False.
906
+
907
+ Returns:
908
+ Path to the generated config.json
909
+
910
+ Raises:
911
+ ValueError: If mesh() hasn't been called yet
912
+
913
+ Example:
914
+ >>> result = sim.mesh("./sim", write_config=False)
915
+ >>> config_path = sim.write_config()
916
+ """
917
+ from gsim.palace.mesh.generator import write_config as gen_write_config
918
+
919
+ if self._last_mesh_result is None:
920
+ raise ValueError("No mesh result. Call mesh() first.")
921
+
922
+ if not self._last_mesh_result.groups:
923
+ raise ValueError(
924
+ "Mesh result has no groups data. "
925
+ "Was mesh() called with write_config=True already?"
926
+ )
927
+
928
+ stack = self._resolve_stack()
929
+ config_path = gen_write_config(
930
+ mesh_result=self._last_mesh_result,
931
+ stack=stack,
932
+ ports=self._last_ports,
933
+ driven_config=self.driven,
934
+ )
935
+
936
+ return config_path
937
+
938
+ # -------------------------------------------------------------------------
939
+ # Simulation
940
+ # -------------------------------------------------------------------------
941
+
942
+ def simulate(
943
+ self,
944
+ *,
945
+ verbose: bool = True,
946
+ ) -> dict[str, Path]:
947
+ """Run simulation on GDSFactory+ cloud.
948
+
949
+ Requires mesh() and write_config() to be called first.
950
+
951
+ Args:
952
+ verbose: Print progress messages
953
+
954
+ Returns:
955
+ Dict mapping result filenames to local paths
956
+
957
+ Raises:
958
+ ValueError: If output_dir not set
959
+ FileNotFoundError: If mesh or config files don't exist
960
+ RuntimeError: If simulation fails
961
+
962
+ Example:
963
+ >>> results = sim.simulate()
964
+ >>> print(f"S-params saved to: {results['port-S.csv']}")
965
+ """
966
+ from gsim.gcloud import run_simulation
967
+
968
+ if self._output_dir is None:
969
+ raise ValueError("Output directory not set. Call set_output_dir() first.")
970
+
971
+ output_dir = self._output_dir
972
+
973
+ return run_simulation(
974
+ output_dir,
975
+ job_type="palace",
976
+ verbose=verbose,
977
+ )
978
+
979
+ def simulate_local(
980
+ self,
981
+ *,
982
+ verbose: bool = True,
983
+ ) -> dict[str, Path]:
984
+ """Run simulation locally using Palace.
985
+
986
+ Requires mesh() and write_config() to be called first,
987
+ and Palace to be installed locally.
988
+
989
+ Args:
990
+ verbose: Print progress messages
991
+
992
+ Returns:
993
+ Dict mapping result filenames to local paths
994
+
995
+ Raises:
996
+ NotImplementedError: Local simulation is not yet implemented
997
+ """
998
+ raise NotImplementedError(
999
+ "Local simulation is not yet implemented. "
1000
+ "Use simulate() to run on GDSFactory+ cloud."
1001
+ )
1002
+
1003
+
1004
+ __all__ = ["DrivenSim"]