diff --git a/src/gsim/palace/TODO.md b/src/gsim/palace/TODO.md deleted file mode 100644 index b5e09ac..0000000 --- a/src/gsim/palace/TODO.md +++ /dev/null @@ -1,92 +0,0 @@ -# Palace Module TODO - -## API Consistency - -### Update EigenmodeSim and ElectrostaticSim -The `DrivenSim` class was updated with a cleaner API. Apply the same changes to: - -- [ ] `EigenmodeSim` (eigenmode.py - 776 lines) -- [ ] `ElectrostaticSim` (electrostatic.py - 621 lines) - -Changes needed: -- Add `set_output_dir()` method -- Remove `output_dir` parameter from `mesh()` -- Remove `output_dir` parameter from `simulate()` -- Add `write_config()` method -- Mesh should only generate mesh, not config - -### Placeholder Methods -- [ ] `simulate_local()` - Currently raises NotImplementedError. Keep as placeholder until local Palace execution is implemented. - -## Code Organization - -### Extract Common Base Class (Mixin) -Create a common mixin with shared functionality. File `src/gsim/palace/base.py` exists with `PalaceSimMixin`. - -Methods to move to mixin (identical across all 3 sim classes): -- [x] `plot_mesh()` - visualize mesh (added) -- [ ] `set_output_dir()` / `output_dir` property -- [ ] `set_geometry()` / `component` / `_component` properties -- [ ] `set_stack()` -- [ ] `set_material()` -- [ ] `set_numerical()` -- [ ] `_resolve_stack()` -- [ ] `_build_mesh_config()` -- [ ] `show_stack()` -- [ ] `plot_stack()` - -Keep in each class (type-specific): -- `validate()` - different validation rules per type -- `add_port()` / `add_cpw_port()` - only Driven/Eigenmode -- `add_terminal()` - only Electrostatic -- `set_driven()` / `set_eigenmode()` / `set_electrostatic()` -- `mesh()` / `simulate()` - similar but type-specific logic - -### Break Down Large Files -Files over 500 lines need refactoring: - -#### mesh/generator.py (1137 lines) -- [ ] Extract geometry functions to `mesh/geometry.py` -- [ ] Extract physical group assignment to `mesh/groups.py` -- [ ] Extract config generation to `mesh/config.py` or `config/generator.py` -- [ ] Keep `generate_mesh()` as the main entry point - -#### driven.py (1003 lines) -- [ ] Move base class methods to `base.py` -- [ ] Consider extracting port configuration to separate module -- [ ] Consider extracting mesh config building logic - -#### eigenmode.py (776 lines) -- [ ] After extracting base class, should be under 500 lines - -#### electrostatic.py (621 lines) -- [ ] After extracting base class, should be under 500 lines - -## File Structure After Refactoring - -``` -src/gsim/palace/ -├── __init__.py -├── base.py # NEW: PalaceSimBase class -├── driven.py # DrivenSim (inherits from base) -├── eigenmode.py # EigenmodeSim (inherits from base) -├── electrostatic.py # ElectrostaticSim (inherits from base) -├── config/ -│ ├── __init__.py -│ └── generator.py # NEW: Palace config.json generation -├── mesh/ -│ ├── __init__.py -│ ├── generator.py # Main generate_mesh() entry point -│ ├── geometry.py # NEW: Geometry extraction -│ ├── groups.py # NEW: Physical group assignment -│ ├── gmsh_utils.py -│ └── pipeline.py -├── models/ -│ └── ... -└── ports/ - └── ... -``` - -## Testing -- [ ] Add tests for the new API (`set_output_dir`, `mesh`, `write_config`, `simulate` flow) -- [ ] Ensure backward compatibility is tested (or document breaking changes) diff --git a/src/gsim/palace/base.py b/src/gsim/palace/base.py index 1b60a3c..6adbb3e 100644 --- a/src/gsim/palace/base.py +++ b/src/gsim/palace/base.py @@ -1,25 +1,328 @@ """Base mixin for Palace simulation classes. -Provides common visualization methods shared across all simulation types. +Provides common methods shared across all simulation types: +DrivenSim, EigenmodeSim, ElectrostaticSim. """ from __future__ import annotations +import warnings from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any, Literal if TYPE_CHECKING: - pass + from gdsfactory.component import Component + + from gsim.common import Geometry, LayerStack + from gsim.palace.models import MaterialConfig, MeshConfig, NumericalConfig class PalaceSimMixin: """Mixin providing common methods for all Palace simulation classes. - Requires the class to have: - - _output_dir: Path | None (private attribute) + Subclasses must define these attributes (typically via Pydantic fields): + - geometry: Geometry | None + - stack: LayerStack | None + - materials: dict[str, MaterialConfig] + - numerical: NumericalConfig + - _output_dir: Path | None (private) + - _stack_kwargs: dict[str, Any] (private) """ + # Type hints for required attributes (implemented by subclasses) + geometry: Geometry | None + stack: LayerStack | None + materials: dict[str, MaterialConfig] + numerical: NumericalConfig _output_dir: Path | None + _stack_kwargs: dict[str, Any] + + # ------------------------------------------------------------------------- + # Output directory + # ------------------------------------------------------------------------- + + def set_output_dir(self, path: str | Path) -> None: + """Set the output directory for mesh and config files. + + Args: + path: Directory path for output files + + Example: + >>> sim.set_output_dir("./palace-sim") + """ + self._output_dir = Path(path) + self._output_dir.mkdir(parents=True, exist_ok=True) + + @property + def output_dir(self) -> Path | None: + """Get the current output directory.""" + return self._output_dir + + # ------------------------------------------------------------------------- + # Geometry methods + # ------------------------------------------------------------------------- + + def set_geometry(self, component: Component) -> None: + """Set the gdsfactory component for simulation. + + Args: + component: gdsfactory Component to simulate + + Example: + >>> sim.set_geometry(my_component) + """ + from gsim.common import Geometry + + self.geometry = Geometry(component=component) + + @property + def component(self) -> Component | None: + """Get the current component (for backward compatibility).""" + return self.geometry.component if self.geometry else None + + # Backward compatibility alias + @property + def _component(self) -> Component | None: + """Internal component access (backward compatibility).""" + return self.component + + # ------------------------------------------------------------------------- + # Stack methods + # ------------------------------------------------------------------------- + + def set_stack( + self, + *, + yaml_path: str | Path | None = None, + air_above: float = 200.0, + substrate_thickness: float = 2.0, + include_substrate: bool = False, + **kwargs, + ) -> None: + """Configure the layer stack. + + If yaml_path is provided, loads stack from YAML file. + Otherwise, extracts from active PDK with given parameters. + + Args: + yaml_path: Path to custom YAML stack file + air_above: Air box height above top metal in um + substrate_thickness: Thickness below z=0 in um + include_substrate: Include lossy silicon substrate + **kwargs: Additional args passed to extract_layer_stack + + Example: + >>> sim.set_stack(air_above=300.0, substrate_thickness=2.0) + """ + self._stack_kwargs = { + "yaml_path": yaml_path, + "air_above": air_above, + "substrate_thickness": substrate_thickness, + "include_substrate": include_substrate, + **kwargs, + } + # Stack will be resolved lazily during mesh() or simulate() + self.stack = None + + # ------------------------------------------------------------------------- + # Material methods + # ------------------------------------------------------------------------- + + def set_material( + self, + name: str, + *, + type: Literal["conductor", "dielectric", "semiconductor"] | None = None, + conductivity: float | None = None, + permittivity: float | None = None, + loss_tangent: float | None = None, + ) -> None: + """Override or add material properties. + + Args: + name: Material name + type: Material type (conductor, dielectric, semiconductor) + conductivity: Conductivity in S/m (for conductors) + permittivity: Relative permittivity (for dielectrics) + loss_tangent: Dielectric loss tangent + + Example: + >>> sim.set_material("aluminum", type="conductor", conductivity=3.8e7) + >>> sim.set_material("sio2", type="dielectric", permittivity=3.9) + """ + from gsim.palace.models import MaterialConfig + + # Determine type if not provided + if type is None: + if conductivity is not None and conductivity > 1e4: + type = "conductor" + elif permittivity is not None: + type = "dielectric" + else: + type = "dielectric" + + self.materials[name] = MaterialConfig( + type=type, + conductivity=conductivity, + permittivity=permittivity, + loss_tangent=loss_tangent, + ) + + def set_numerical( + self, + *, + order: int = 2, + tolerance: float = 1e-6, + max_iterations: int = 400, + solver_type: Literal["Default", "SuperLU", "STRUMPACK", "MUMPS"] = "Default", + preconditioner: Literal["Default", "AMS", "BoomerAMG"] = "Default", + device: Literal["CPU", "GPU"] = "CPU", + num_processors: int | None = None, + ) -> None: + """Configure numerical solver parameters. + + Args: + order: Finite element order (1-4) + tolerance: Linear solver tolerance + max_iterations: Maximum solver iterations + solver_type: Linear solver type + preconditioner: Preconditioner type + device: Compute device (CPU or GPU) + num_processors: Number of processors (None = auto) + + Example: + >>> sim.set_numerical(order=3, tolerance=1e-8) + """ + from gsim.palace.models import NumericalConfig + + self.numerical = NumericalConfig( + order=order, + tolerance=tolerance, + max_iterations=max_iterations, + solver_type=solver_type, + preconditioner=preconditioner, + device=device, + num_processors=num_processors, + ) + + # ------------------------------------------------------------------------- + # Internal helpers + # ------------------------------------------------------------------------- + + def _resolve_stack(self) -> LayerStack: + """Resolve the layer stack from PDK or YAML. + + Returns: + Legacy LayerStack object for mesh generation + """ + from gsim.common.stack import get_stack + + yaml_path = self._stack_kwargs.pop("yaml_path", None) + legacy_stack = get_stack(yaml_path=yaml_path, **self._stack_kwargs) + + # Restore yaml_path for potential re-resolution + self._stack_kwargs["yaml_path"] = yaml_path + + # Apply material overrides + for name, props in self.materials.items(): + legacy_stack.materials[name] = props.to_dict() + + # Store the LayerStack + self.stack = legacy_stack + + return legacy_stack + + def _build_mesh_config( + self, + preset: Literal["coarse", "default", "fine"] | None, + refined_mesh_size: float | None, + max_mesh_size: float | None, + margin: float | None, + air_above: float | None, + fmax: float | None, + show_gui: bool, + ) -> MeshConfig: + """Build mesh config from preset with optional overrides.""" + from gsim.palace.models import MeshConfig + + # Build mesh config from preset + if preset == "coarse": + mesh_config = MeshConfig.coarse() + elif preset == "fine": + mesh_config = MeshConfig.fine() + else: + mesh_config = MeshConfig.default() + + # Track overrides for warning + overrides = [] + if preset is not None: + if refined_mesh_size is not None: + overrides.append(f"refined_mesh_size={refined_mesh_size}") + if max_mesh_size is not None: + overrides.append(f"max_mesh_size={max_mesh_size}") + if margin is not None: + overrides.append(f"margin={margin}") + if air_above is not None: + overrides.append(f"air_above={air_above}") + if fmax is not None: + overrides.append(f"fmax={fmax}") + + if overrides: + warnings.warn( + f"Preset '{preset}' values overridden by: {', '.join(overrides)}", + stacklevel=4, + ) + + # Apply overrides + if refined_mesh_size is not None: + mesh_config.refined_mesh_size = refined_mesh_size + if max_mesh_size is not None: + mesh_config.max_mesh_size = max_mesh_size + if margin is not None: + mesh_config.margin = margin + if air_above is not None: + mesh_config.air_above = air_above + if fmax is not None: + mesh_config.fmax = fmax + mesh_config.show_gui = show_gui + + return mesh_config + + # ------------------------------------------------------------------------- + # Convenience methods + # ------------------------------------------------------------------------- + + def show_stack(self) -> None: + """Print the layer stack table. + + Example: + >>> sim.show_stack() + """ + from gsim.common.stack import print_stack_table + + if self.stack is None: + self._resolve_stack() + + if self.stack is not None: + print_stack_table(self.stack) + + def plot_stack(self) -> None: + """Plot the layer stack visualization. + + Example: + >>> sim.plot_stack() + """ + from gsim.common.stack import plot_stack + + if self.stack is None: + self._resolve_stack() + + if self.stack is not None: + plot_stack(self.stack) + + # ------------------------------------------------------------------------- + # Visualization + # ------------------------------------------------------------------------- def plot_mesh( self, diff --git a/src/gsim/palace/driven.py b/src/gsim/palace/driven.py index 7bdefa2..7bc6365 100644 --- a/src/gsim/palace/driven.py +++ b/src/gsim/palace/driven.py @@ -8,7 +8,6 @@ import logging import tempfile -import warnings from pathlib import Path logger = logging.getLogger(__name__) @@ -95,91 +94,6 @@ class DrivenSim(PalaceSimMixin, BaseModel): _last_mesh_result: Any = PrivateAttr(default=None) _last_ports: list = PrivateAttr(default_factory=list) - # ------------------------------------------------------------------------- - # Output directory - # ------------------------------------------------------------------------- - - def set_output_dir(self, path: str | Path) -> None: - """Set the output directory for mesh and config files. - - Args: - path: Directory path for output files - - Example: - >>> sim.set_output_dir("./palace-sim") - """ - self._output_dir = Path(path) - self._output_dir.mkdir(parents=True, exist_ok=True) - - @property - def output_dir(self) -> Path | None: - """Get the current output directory.""" - return self._output_dir - - # ------------------------------------------------------------------------- - # Geometry methods - # ------------------------------------------------------------------------- - - def set_geometry(self, component: Component) -> None: - """Set the gdsfactory component for simulation. - - Args: - component: gdsfactory Component to simulate - - Example: - >>> sim.set_geometry(my_component) - """ - self.geometry = Geometry(component=component) - - @property - def component(self) -> Component | None: - """Get the current component (for backward compatibility).""" - return self.geometry.component if self.geometry else None - - # Backward compatibility alias - @property - def _component(self) -> Component | None: - """Internal component access (backward compatibility).""" - return self.component - - # ------------------------------------------------------------------------- - # Stack methods - # ------------------------------------------------------------------------- - - def set_stack( - self, - *, - yaml_path: str | Path | None = None, - air_above: float = 200.0, - substrate_thickness: float = 2.0, - include_substrate: bool = False, - **kwargs, - ) -> None: - """Configure the layer stack. - - If yaml_path is provided, loads stack from YAML file. - Otherwise, extracts from active PDK with given parameters. - - Args: - yaml_path: Path to custom YAML stack file - air_above: Air box height above top metal in um - substrate_thickness: Thickness below z=0 in um - include_substrate: Include lossy silicon substrate - **kwargs: Additional args passed to extract_layer_stack - - Example: - >>> sim.set_stack(air_above=300.0, substrate_thickness=2.0) - """ - self._stack_kwargs = { - "yaml_path": yaml_path, - "air_above": air_above, - "substrate_thickness": substrate_thickness, - "include_substrate": include_substrate, - **kwargs, - } - # Stack will be resolved lazily during mesh() or simulate() - self.stack = None - # ------------------------------------------------------------------------- # Port methods # ------------------------------------------------------------------------- @@ -318,83 +232,6 @@ def set_driven( excitation_port=excitation_port, ) - # ------------------------------------------------------------------------- - # Material methods - # ------------------------------------------------------------------------- - - def set_material( - self, - name: str, - *, - type: Literal["conductor", "dielectric", "semiconductor"] | None = None, - conductivity: float | None = None, - permittivity: float | None = None, - loss_tangent: float | None = None, - ) -> None: - """Override or add material properties. - - Args: - name: Material name - type: Material type (conductor, dielectric, semiconductor) - conductivity: Conductivity in S/m (for conductors) - permittivity: Relative permittivity (for dielectrics) - loss_tangent: Dielectric loss tangent - - Example: - >>> sim.set_material("aluminum", type="conductor", conductivity=3.8e7) - >>> sim.set_material("sio2", type="dielectric", permittivity=3.9) - """ - # Determine type if not provided - if type is None: - if conductivity is not None and conductivity > 1e4: - type = "conductor" - elif permittivity is not None: - type = "dielectric" - else: - type = "dielectric" - - self.materials[name] = MaterialConfig( - type=type, - conductivity=conductivity, - permittivity=permittivity, - loss_tangent=loss_tangent, - ) - - def set_numerical( - self, - *, - order: int = 2, - tolerance: float = 1e-6, - max_iterations: int = 400, - solver_type: Literal["Default", "SuperLU", "STRUMPACK", "MUMPS"] = "Default", - preconditioner: Literal["Default", "AMS", "BoomerAMG"] = "Default", - device: Literal["CPU", "GPU"] = "CPU", - num_processors: int | None = None, - ) -> None: - """Configure numerical solver parameters. - - Args: - order: Finite element order (1-4) - tolerance: Linear solver tolerance - max_iterations: Maximum solver iterations - solver_type: Linear solver type - preconditioner: Preconditioner type - device: Compute device (CPU or GPU) - num_processors: Number of processors (None = auto) - - Example: - >>> sim.set_numerical(order=3, tolerance=1e-8) - """ - self.numerical = NumericalConfig( - order=order, - tolerance=tolerance, - max_iterations=max_iterations, - solver_type=solver_type, - preconditioner=preconditioner, - device=device, - num_processors=num_processors, - ) - # ------------------------------------------------------------------------- # Validation # ------------------------------------------------------------------------- @@ -463,29 +300,6 @@ def validate(self) -> ValidationResult: # Internal helpers # ------------------------------------------------------------------------- - def _resolve_stack(self) -> LayerStack: - """Resolve the layer stack from PDK or YAML. - - Returns: - Legacy LayerStack object for mesh generation - """ - from gsim.common.stack import get_stack - - yaml_path = self._stack_kwargs.pop("yaml_path", None) - legacy_stack = get_stack(yaml_path=yaml_path, **self._stack_kwargs) - - # Restore yaml_path for potential re-resolution - self._stack_kwargs["yaml_path"] = yaml_path - - # Apply material overrides - for name, props in self.materials.items(): - legacy_stack.materials[name] = props.to_dict() - - # Store the LayerStack - self.stack = legacy_stack - - return legacy_stack - def _configure_ports_on_component(self, stack: LayerStack) -> None: """Configure ports on the component using legacy functions.""" from gsim.palace.ports import ( @@ -571,60 +385,6 @@ def _configure_ports_on_component(self, stack: LayerStack) -> None: self._configured_ports = True - def _build_mesh_config( - self, - preset: Literal["coarse", "default", "fine"] | None, - refined_mesh_size: float | None, - max_mesh_size: float | None, - margin: float | None, - air_above: float | None, - fmax: float | None, - show_gui: bool, - ) -> MeshConfig: - """Build mesh config from preset with optional overrides.""" - # Build mesh config from preset - if preset == "coarse": - mesh_config = MeshConfig.coarse() - elif preset == "fine": - mesh_config = MeshConfig.fine() - else: - mesh_config = MeshConfig.default() - - # Track overrides for warning - overrides = [] - if preset is not None: - if refined_mesh_size is not None: - overrides.append(f"refined_mesh_size={refined_mesh_size}") - if max_mesh_size is not None: - overrides.append(f"max_mesh_size={max_mesh_size}") - if margin is not None: - overrides.append(f"margin={margin}") - if air_above is not None: - overrides.append(f"air_above={air_above}") - if fmax is not None: - overrides.append(f"fmax={fmax}") - - if overrides: - warnings.warn( - f"Preset '{preset}' values overridden by: {', '.join(overrides)}", - stacklevel=4, - ) - - # Apply overrides - if refined_mesh_size is not None: - mesh_config.refined_mesh_size = refined_mesh_size - if max_mesh_size is not None: - mesh_config.max_mesh_size = max_mesh_size - if margin is not None: - mesh_config.margin = margin - if air_above is not None: - mesh_config.air_above = air_above - if fmax is not None: - mesh_config.fmax = fmax - mesh_config.show_gui = show_gui - - return mesh_config - def _generate_mesh_internal( self, output_dir: Path, @@ -776,38 +536,6 @@ def preview( config=legacy_mesh_config, ) - # ------------------------------------------------------------------------- - # Convenience methods - # ------------------------------------------------------------------------- - - def show_stack(self) -> None: - """Print the layer stack table. - - Example: - >>> sim.show_stack() - """ - from gsim.common.stack import print_stack_table - - if self.stack is None: - self._resolve_stack() - - if self.stack is not None: - print_stack_table(self.stack) - - def plot_stack(self) -> None: - """Plot the layer stack visualization. - - Example: - >>> sim.plot_stack() - """ - from gsim.common.stack import plot_stack - - if self.stack is None: - self._resolve_stack() - - if self.stack is not None: - plot_stack(self.stack) - # ------------------------------------------------------------------------- # Mesh generation # ------------------------------------------------------------------------- diff --git a/src/gsim/palace/eigenmode.py b/src/gsim/palace/eigenmode.py index 4cc64e9..97ba1f2 100644 --- a/src/gsim/palace/eigenmode.py +++ b/src/gsim/palace/eigenmode.py @@ -8,7 +8,6 @@ import logging import tempfile -import warnings from pathlib import Path logger = logging.getLogger(__name__) @@ -37,8 +36,7 @@ class EigenmodeSim(PalaceSimMixin, BaseModel): """Eigenmode simulation for finding resonant frequencies. This class configures and runs eigenmode simulations to find - resonant frequencies and mode shapes of structures. Uses composition - (no inheritance) with shared Geometry and Stack components from gsim.common. + resonant frequencies and mode shapes of structures. Example: >>> from gsim.palace import EigenmodeSim @@ -48,7 +46,8 @@ class EigenmodeSim(PalaceSimMixin, BaseModel): >>> sim.set_stack(air_above=300.0) >>> sim.add_port("o1", layer="topmetal2", length=5.0) >>> sim.set_eigenmode(num_modes=10, target=50e9) - >>> sim.mesh("./sim", preset="default") + >>> sim.set_output_dir("./sim") + >>> sim.mesh(preset="default") >>> results = sim.simulate() Attributes: @@ -89,66 +88,7 @@ class EigenmodeSim(PalaceSimMixin, BaseModel): _configured_ports: bool = PrivateAttr(default=False) # ------------------------------------------------------------------------- - # Geometry methods - # ------------------------------------------------------------------------- - - def set_geometry(self, component: Component) -> None: - """Set the gdsfactory component for simulation. - - Args: - component: gdsfactory Component to simulate - - Example: - >>> sim.set_geometry(my_component) - """ - self.geometry = Geometry(component=component) - - @property - def component(self) -> Component | None: - """Get the current component (for backward compatibility).""" - return self.geometry.component if self.geometry else None - - @property - def _component(self) -> Component | None: - """Internal component access (backward compatibility).""" - return self.component - - # ------------------------------------------------------------------------- - # Stack methods - # ------------------------------------------------------------------------- - - def set_stack( - self, - *, - yaml_path: str | Path | None = None, - air_above: float = 200.0, - substrate_thickness: float = 2.0, - include_substrate: bool = False, - **kwargs, - ) -> None: - """Configure the layer stack. - - Args: - yaml_path: Path to custom YAML stack file - air_above: Air box height above top metal in um - substrate_thickness: Thickness below z=0 in um - include_substrate: Include lossy silicon substrate - **kwargs: Additional args passed to extract_layer_stack - - Example: - >>> sim.set_stack(air_above=300.0, substrate_thickness=2.0) - """ - self._stack_kwargs = { - "yaml_path": yaml_path, - "air_above": air_above, - "substrate_thickness": substrate_thickness, - "include_substrate": include_substrate, - **kwargs, - } - self.stack = None - - # ------------------------------------------------------------------------- - # Port methods + # Port methods (Eigenmode can have ports for Q-factor calculation) # ------------------------------------------------------------------------- def add_port( @@ -259,80 +199,6 @@ def set_eigenmode( tolerance=tolerance, ) - # ------------------------------------------------------------------------- - # Material methods - # ------------------------------------------------------------------------- - - def set_material( - self, - name: str, - *, - type: Literal["conductor", "dielectric", "semiconductor"] | None = None, - conductivity: float | None = None, - permittivity: float | None = None, - loss_tangent: float | None = None, - ) -> None: - """Override or add material properties. - - Args: - name: Material name - type: Material type (conductor, dielectric, semiconductor) - conductivity: Conductivity in S/m (for conductors) - permittivity: Relative permittivity (for dielectrics) - loss_tangent: Dielectric loss tangent - - Example: - >>> sim.set_material("aluminum", type="conductor", conductivity=3.8e7) - """ - if type is None: - if conductivity is not None and conductivity > 1e4: - type = "conductor" - elif permittivity is not None: - type = "dielectric" - else: - type = "dielectric" - - self.materials[name] = MaterialConfig( - type=type, - conductivity=conductivity, - permittivity=permittivity, - loss_tangent=loss_tangent, - ) - - def set_numerical( - self, - *, - order: int = 2, - tolerance: float = 1e-6, - max_iterations: int = 400, - solver_type: Literal["Default", "SuperLU", "STRUMPACK", "MUMPS"] = "Default", - preconditioner: Literal["Default", "AMS", "BoomerAMG"] = "Default", - device: Literal["CPU", "GPU"] = "CPU", - num_processors: int | None = None, - ) -> None: - """Configure numerical solver parameters. - - Args: - order: Finite element order (1-4) - tolerance: Linear solver tolerance - max_iterations: Maximum solver iterations - solver_type: Linear solver type - preconditioner: Preconditioner type - device: Compute device (CPU or GPU) - num_processors: Number of processors (None = auto) - - Example: - >>> sim.set_numerical(order=3, tolerance=1e-8) - """ - self.numerical = NumericalConfig( - order=order, - tolerance=tolerance, - max_iterations=max_iterations, - solver_type=solver_type, - preconditioner=preconditioner, - device=device, - num_processors=num_processors, - ) # ------------------------------------------------------------------------- # Validation @@ -380,21 +246,6 @@ def validate(self) -> ValidationResult: # Internal helpers # ------------------------------------------------------------------------- - def _resolve_stack(self) -> LayerStack: - """Resolve the layer stack from PDK or YAML.""" - from gsim.common.stack import get_stack - - yaml_path = self._stack_kwargs.pop("yaml_path", None) - legacy_stack = get_stack(yaml_path=yaml_path, **self._stack_kwargs) - self._stack_kwargs["yaml_path"] = yaml_path - - for name, props in self.materials.items(): - legacy_stack.materials[name] = props.to_dict() - - self.stack = legacy_stack - - return legacy_stack - def _configure_ports_on_component(self, stack: LayerStack) -> None: """Configure ports on the component.""" from gsim.palace.ports import ( @@ -466,57 +317,6 @@ def _configure_ports_on_component(self, stack: LayerStack) -> None: self._configured_ports = True - def _build_mesh_config( - self, - preset: Literal["coarse", "default", "fine"] | None, - refined_mesh_size: float | None, - max_mesh_size: float | None, - margin: float | None, - air_above: float | None, - fmax: float | None, - show_gui: bool, - ) -> MeshConfig: - """Build mesh config from preset with optional overrides.""" - if preset == "coarse": - mesh_config = MeshConfig.coarse() - elif preset == "fine": - mesh_config = MeshConfig.fine() - else: - mesh_config = MeshConfig.default() - - overrides = [] - if preset is not None: - if refined_mesh_size is not None: - overrides.append(f"refined_mesh_size={refined_mesh_size}") - if max_mesh_size is not None: - overrides.append(f"max_mesh_size={max_mesh_size}") - if margin is not None: - overrides.append(f"margin={margin}") - if air_above is not None: - overrides.append(f"air_above={air_above}") - if fmax is not None: - overrides.append(f"fmax={fmax}") - - if overrides: - warnings.warn( - f"Preset '{preset}' values overridden by: {', '.join(overrides)}", - stacklevel=4, - ) - - if refined_mesh_size is not None: - mesh_config.refined_mesh_size = refined_mesh_size - if max_mesh_size is not None: - mesh_config.max_mesh_size = max_mesh_size - if margin is not None: - mesh_config.margin = margin - if air_above is not None: - mesh_config.air_above = air_above - if fmax is not None: - mesh_config.fmax = fmax - mesh_config.show_gui = show_gui - - return mesh_config - def _generate_mesh_internal( self, output_dir: Path, @@ -648,37 +448,12 @@ def preview( config=legacy_mesh_config, ) - # ------------------------------------------------------------------------- - # Convenience methods - # ------------------------------------------------------------------------- - - def show_stack(self) -> None: - """Print the layer stack table.""" - from gsim.common.stack import print_stack_table - - if self.stack is None: - self._resolve_stack() - - if self.stack is not None: - print_stack_table(self.stack) - - def plot_stack(self) -> None: - """Plot the layer stack visualization.""" - from gsim.common.stack import plot_stack - - if self.stack is None: - self._resolve_stack() - - if self.stack is not None: - plot_stack(self.stack) - # ------------------------------------------------------------------------- # Mesh generation # ------------------------------------------------------------------------- def mesh( self, - output_dir: str | Path, *, preset: Literal["coarse", "default", "fine"] | None = None, refined_mesh_size: float | None = None, @@ -690,25 +465,37 @@ def mesh( model_name: str = "palace", verbose: bool = True, ) -> SimulationResult: - """Generate the mesh and configuration files. + """Generate the mesh for Palace simulation. + + Requires set_output_dir() to be called first. Args: - output_dir: Directory for output files preset: Mesh quality preset ("coarse", "default", "fine") - refined_mesh_size: Mesh size near conductors (um) - max_mesh_size: Max mesh size in air/dielectric (um) - margin: XY margin around design (um) - air_above: Air above top metal (um) - fmax: Max frequency for mesh sizing (Hz) + refined_mesh_size: Mesh size near conductors (um), overrides preset + max_mesh_size: Max mesh size in air/dielectric (um), overrides preset + margin: XY margin around design (um), overrides preset + air_above: Air above top metal (um), overrides preset + fmax: Max frequency for mesh sizing (Hz), overrides preset show_gui: Show gmsh GUI during meshing model_name: Base name for output files verbose: Print progress messages Returns: - SimulationResult with mesh and config paths + SimulationResult with mesh path + + Raises: + ValueError: If output_dir not set or configuration is invalid + + Example: + >>> sim.set_output_dir("./sim") + >>> result = sim.mesh(preset="fine") + >>> print(f"Mesh saved to: {result.mesh_path}") """ from gsim.palace.ports import extract_ports + if self._output_dir is None: + raise ValueError("Output directory not set. Call set_output_dir() first.") + component = self.geometry.component if self.geometry else None mesh_config = self._build_mesh_config( @@ -727,9 +514,7 @@ def mesh( f"Invalid configuration:\n" + "\n".join(validation.errors) ) - output_dir = Path(output_dir) - output_dir.mkdir(parents=True, exist_ok=True) - self._output_dir = output_dir + output_dir = self._output_dir stack = self._resolve_stack() diff --git a/src/gsim/palace/electrostatic.py b/src/gsim/palace/electrostatic.py index 88cb458..d7ac56f 100644 --- a/src/gsim/palace/electrostatic.py +++ b/src/gsim/palace/electrostatic.py @@ -8,7 +8,6 @@ import logging import tempfile -import warnings from pathlib import Path logger = logging.getLogger(__name__) @@ -37,8 +36,7 @@ class ElectrostaticSim(PalaceSimMixin, BaseModel): This class configures and runs electrostatic simulations to extract the capacitance matrix between conductor terminals. Unlike driven - and eigenmode simulations, this does not use ports. Uses composition - (no inheritance) with shared Geometry and Stack components from gsim.common. + and eigenmode simulations, this does not use ports. Example: >>> from gsim.palace import ElectrostaticSim @@ -49,7 +47,8 @@ class ElectrostaticSim(PalaceSimMixin, BaseModel): >>> sim.add_terminal("T1", layer="topmetal2") >>> sim.add_terminal("T2", layer="topmetal2") >>> sim.set_electrostatic() - >>> sim.mesh("./sim", preset="default") + >>> sim.set_output_dir("./sim") + >>> sim.mesh(preset="default") >>> results = sim.simulate() Attributes: @@ -87,65 +86,6 @@ class ElectrostaticSim(PalaceSimMixin, BaseModel): _output_dir: Path | None = PrivateAttr(default=None) _configured_terminals: bool = PrivateAttr(default=False) - # ------------------------------------------------------------------------- - # Geometry methods - # ------------------------------------------------------------------------- - - def set_geometry(self, component: Component) -> None: - """Set the gdsfactory component for simulation. - - Args: - component: gdsfactory Component to simulate - - Example: - >>> sim.set_geometry(my_component) - """ - self.geometry = Geometry(component=component) - - @property - def component(self) -> Component | None: - """Get the current component (for backward compatibility).""" - return self.geometry.component if self.geometry else None - - @property - def _component(self) -> Component | None: - """Internal component access (backward compatibility).""" - return self.component - - # ------------------------------------------------------------------------- - # Stack methods - # ------------------------------------------------------------------------- - - def set_stack( - self, - *, - yaml_path: str | Path | None = None, - air_above: float = 200.0, - substrate_thickness: float = 2.0, - include_substrate: bool = False, - **kwargs, - ) -> None: - """Configure the layer stack. - - Args: - yaml_path: Path to custom YAML stack file - air_above: Air box height above top metal in um - substrate_thickness: Thickness below z=0 in um - include_substrate: Include lossy silicon substrate - **kwargs: Additional args passed to extract_layer_stack - - Example: - >>> sim.set_stack(air_above=300.0, substrate_thickness=2.0) - """ - self._stack_kwargs = { - "yaml_path": yaml_path, - "air_above": air_above, - "substrate_thickness": substrate_thickness, - "include_substrate": include_substrate, - **kwargs, - } - self.stack = None - # ------------------------------------------------------------------------- # Terminal methods # ------------------------------------------------------------------------- @@ -198,80 +138,6 @@ def set_electrostatic( save_fields=save_fields, ) - # ------------------------------------------------------------------------- - # Material methods - # ------------------------------------------------------------------------- - - def set_material( - self, - name: str, - *, - type: Literal["conductor", "dielectric", "semiconductor"] | None = None, - conductivity: float | None = None, - permittivity: float | None = None, - loss_tangent: float | None = None, - ) -> None: - """Override or add material properties. - - Args: - name: Material name - type: Material type (conductor, dielectric, semiconductor) - conductivity: Conductivity in S/m (for conductors) - permittivity: Relative permittivity (for dielectrics) - loss_tangent: Dielectric loss tangent - - Example: - >>> sim.set_material("aluminum", type="conductor", conductivity=3.8e7) - """ - if type is None: - if conductivity is not None and conductivity > 1e4: - type = "conductor" - elif permittivity is not None: - type = "dielectric" - else: - type = "dielectric" - - self.materials[name] = MaterialConfig( - type=type, - conductivity=conductivity, - permittivity=permittivity, - loss_tangent=loss_tangent, - ) - - def set_numerical( - self, - *, - order: int = 2, - tolerance: float = 1e-6, - max_iterations: int = 400, - solver_type: Literal["Default", "SuperLU", "STRUMPACK", "MUMPS"] = "Default", - preconditioner: Literal["Default", "AMS", "BoomerAMG"] = "Default", - device: Literal["CPU", "GPU"] = "CPU", - num_processors: int | None = None, - ) -> None: - """Configure numerical solver parameters. - - Args: - order: Finite element order (1-4) - tolerance: Linear solver tolerance - max_iterations: Maximum solver iterations - solver_type: Linear solver type - preconditioner: Preconditioner type - device: Compute device (CPU or GPU) - num_processors: Number of processors (None = auto) - - Example: - >>> sim.set_numerical(order=3, tolerance=1e-8) - """ - self.numerical = NumericalConfig( - order=order, - tolerance=tolerance, - max_iterations=max_iterations, - solver_type=solver_type, - preconditioner=preconditioner, - device=device, - num_processors=num_processors, - ) # ------------------------------------------------------------------------- # Validation @@ -315,71 +181,6 @@ def validate(self) -> ValidationResult: # Internal helpers # ------------------------------------------------------------------------- - def _resolve_stack(self) -> LayerStack: - """Resolve the layer stack from PDK or YAML.""" - from gsim.common.stack import get_stack - - yaml_path = self._stack_kwargs.pop("yaml_path", None) - stack = get_stack(yaml_path=yaml_path, **self._stack_kwargs) - self._stack_kwargs["yaml_path"] = yaml_path - - for name, props in self.materials.items(): - stack.materials[name] = props.to_dict() - - self.stack = stack - return stack - - def _build_mesh_config( - self, - preset: Literal["coarse", "default", "fine"] | None, - refined_mesh_size: float | None, - max_mesh_size: float | None, - margin: float | None, - air_above: float | None, - fmax: float | None, - show_gui: bool, - ) -> MeshConfig: - """Build mesh config from preset with optional overrides.""" - if preset == "coarse": - mesh_config = MeshConfig.coarse() - elif preset == "fine": - mesh_config = MeshConfig.fine() - else: - mesh_config = MeshConfig.default() - - overrides = [] - if preset is not None: - if refined_mesh_size is not None: - overrides.append(f"refined_mesh_size={refined_mesh_size}") - if max_mesh_size is not None: - overrides.append(f"max_mesh_size={max_mesh_size}") - if margin is not None: - overrides.append(f"margin={margin}") - if air_above is not None: - overrides.append(f"air_above={air_above}") - if fmax is not None: - overrides.append(f"fmax={fmax}") - - if overrides: - warnings.warn( - f"Preset '{preset}' values overridden by: {', '.join(overrides)}", - stacklevel=4, - ) - - if refined_mesh_size is not None: - mesh_config.refined_mesh_size = refined_mesh_size - if max_mesh_size is not None: - mesh_config.max_mesh_size = max_mesh_size - if margin is not None: - mesh_config.margin = margin - if air_above is not None: - mesh_config.air_above = air_above - if fmax is not None: - mesh_config.fmax = fmax - mesh_config.show_gui = show_gui - - return mesh_config - def _generate_mesh_internal( self, output_dir: Path, @@ -427,10 +228,6 @@ def _generate_mesh_internal( mesh_stats=mesh_result.mesh_stats, ) - def _get_ports_for_preview(self, stack: LayerStack) -> list: - """Get ports for preview (none for electrostatic).""" - return [] - # ------------------------------------------------------------------------- # Preview # ------------------------------------------------------------------------- @@ -503,37 +300,12 @@ def preview( config=legacy_mesh_config, ) - # ------------------------------------------------------------------------- - # Convenience methods - # ------------------------------------------------------------------------- - - def show_stack(self) -> None: - """Print the layer stack table.""" - from gsim.common.stack import print_stack_table - - if self.stack is None: - self._resolve_stack() - - if self.stack is not None: - print_stack_table(self.stack) - - def plot_stack(self) -> None: - """Plot the layer stack visualization.""" - from gsim.common.stack import plot_stack - - if self.stack is None: - self._resolve_stack() - - if self.stack is not None: - plot_stack(self.stack) - # ------------------------------------------------------------------------- # Mesh generation # ------------------------------------------------------------------------- def mesh( self, - output_dir: str | Path, *, preset: Literal["coarse", "default", "fine"] | None = None, refined_mesh_size: float | None = None, @@ -545,23 +317,35 @@ def mesh( model_name: str = "palace", verbose: bool = True, ) -> SimulationResult: - """Generate the mesh and configuration files. + """Generate the mesh for Palace simulation. + + Requires set_output_dir() to be called first. Args: - output_dir: Directory for output files preset: Mesh quality preset ("coarse", "default", "fine") - refined_mesh_size: Mesh size near conductors (um) - max_mesh_size: Max mesh size in air/dielectric (um) - margin: XY margin around design (um) - air_above: Air above top metal (um) + refined_mesh_size: Mesh size near conductors (um), overrides preset + max_mesh_size: Max mesh size in air/dielectric (um), overrides preset + margin: XY margin around design (um), overrides preset + air_above: Air above top metal (um), overrides preset fmax: Max frequency for mesh sizing (Hz) - less relevant for electrostatic show_gui: Show gmsh GUI during meshing model_name: Base name for output files verbose: Print progress messages Returns: - SimulationResult with mesh and config paths + SimulationResult with mesh path + + Raises: + ValueError: If output_dir not set or configuration is invalid + + Example: + >>> sim.set_output_dir("./sim") + >>> result = sim.mesh(preset="fine") + >>> print(f"Mesh saved to: {result.mesh_path}") """ + if self._output_dir is None: + raise ValueError("Output directory not set. Call set_output_dir() first.") + mesh_config = self._build_mesh_config( preset=preset, refined_mesh_size=refined_mesh_size, @@ -578,9 +362,7 @@ def mesh( f"Invalid configuration:\n" + "\n".join(validation.errors) ) - output_dir = Path(output_dir) - output_dir.mkdir(parents=True, exist_ok=True) - self._output_dir = output_dir + output_dir = self._output_dir self._resolve_stack() diff --git a/src/gsim/palace/mesh/__init__.py b/src/gsim/palace/mesh/__init__.py index a089b88..73ed7c4 100644 --- a/src/gsim/palace/mesh/__init__.py +++ b/src/gsim/palace/mesh/__init__.py @@ -28,7 +28,12 @@ from __future__ import annotations -from gsim.palace.mesh.generator import generate_mesh as generate_mesh_direct +from gsim.palace.mesh.generator import ( + GeometryData, + MeshResult as MeshResultDirect, + generate_mesh as generate_mesh_direct, + write_config, +) from gsim.palace.mesh.pipeline import ( GroundPlane, MeshConfig, @@ -40,11 +45,17 @@ from . import gmsh_utils __all__ = [ + # Pipeline API (recommended) "GroundPlane", "MeshConfig", "MeshPreset", "MeshResult", "generate_mesh", + # Direct API + "GeometryData", + "MeshResultDirect", "generate_mesh_direct", + "write_config", + # Utilities "gmsh_utils", ] diff --git a/src/gsim/palace/mesh/config_generator.py b/src/gsim/palace/mesh/config_generator.py new file mode 100644 index 0000000..f64d7e1 --- /dev/null +++ b/src/gsim/palace/mesh/config_generator.py @@ -0,0 +1,367 @@ +"""Palace configuration file generation. + +This module handles generating Palace config.json and collecting mesh statistics. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import TYPE_CHECKING + +import gmsh + +if TYPE_CHECKING: + from gsim.common.stack import LayerStack + from gsim.palace.models import DrivenConfig + from gsim.palace.ports.config import PalacePort + + +def generate_palace_config( + groups: dict, + ports: list[PalacePort], + port_info: list, + stack: LayerStack, + output_path: Path, + model_name: str, + fmax: float, + driven_config: DrivenConfig | None = None, +) -> Path: + """Generate Palace config.json file. + + Args: + groups: Physical group information from mesh generation + ports: List of PalacePort objects + port_info: Port metadata list + stack: Layer stack for material properties + output_path: Output directory path + model_name: Base name for output files + fmax: Maximum frequency (Hz) - used as fallback if driven_config not provided + driven_config: Optional DrivenConfig for frequency sweep settings + + Returns: + Path to the generated config.json + """ + from gsim.palace.ports.config import PortGeometry + + # Use driven_config if provided, otherwise fall back to legacy parameters + if driven_config is not None: + solver_driven = driven_config.to_palace_config() + else: + # Legacy behavior - compute from fmax + freq_step = fmax / 40e9 + solver_driven = { + "Samples": [ + { + "Type": "Linear", + "MinFreq": 1.0, # 1 GHz + "MaxFreq": fmax / 1e9, + "FreqStep": freq_step, + "SaveStep": 0, + } + ], + "AdaptiveTol": 0.02, + } + + config: dict[str, object] = { + "Problem": { + "Type": "Driven", + "Verbose": 3, + "Output": f"output/{model_name}", + }, + "Model": { + "Mesh": f"{model_name}.msh", + "L0": 1e-6, # um + "Refinement": { + "UniformLevels": 0, + "Tol": 1e-2, + "MaxIts": 0, + }, + }, + "Solver": { + "Linear": { + "Type": "Default", + "KSPType": "GMRES", + "Tol": 1e-6, + "MaxIts": 400, + }, + "Order": 2, + "Device": "CPU", + "Driven": solver_driven, + }, + } + + # Build domains section + materials: list[dict[str, object]] = [] + for material_name, info in groups["volumes"].items(): + mat_props = stack.materials.get(material_name, {}) + mat_entry: dict[str, object] = {"Attributes": [info["phys_group"]]} + + if material_name == "airbox": + mat_entry["Permittivity"] = 1.0 + mat_entry["LossTan"] = 0.0 + else: + mat_entry["Permittivity"] = mat_props.get("permittivity", 1.0) + sigma = mat_props.get("conductivity", 0.0) + if sigma > 0: + mat_entry["Conductivity"] = sigma + else: + mat_entry["LossTan"] = mat_props.get("loss_tangent", 0.0) + + materials.append(mat_entry) + + config["Domains"] = { + "Materials": materials, + "Postprocessing": {"Energy": [], "Probe": []}, + } + + # Build boundaries section + conductors: list[dict[str, object]] = [] + for name, info in groups["conductor_surfaces"].items(): + # Extract layer name from "layer_xy" or "layer_z" + layer_name = name.rsplit("_", 1)[0] + layer = stack.layers.get(layer_name) + if layer: + mat_props = stack.materials.get(layer.material, {}) + conductors.append( + { + "Attributes": [info["phys_group"]], + "Conductivity": mat_props.get("conductivity", 5.8e7), + "Thickness": layer.zmax - layer.zmin, + } + ) + + lumped_ports: list[dict[str, object]] = [] + port_idx = 1 + + for port in ports: + port_key = f"P{port_idx}" + if port_key in groups["port_surfaces"]: + port_group = groups["port_surfaces"][port_key] + + if port.multi_element: + # Multi-element port (CPW) + if port_group.get("type") == "cpw": + elements = [ + { + "Attributes": [elem["phys_group"]], + "Direction": elem["direction"], + } + for elem in port_group["elements"] + ] + + lumped_ports.append( + { + "Index": port_idx, + "R": port.impedance, + "Excitation": port_idx if port.excited else False, + "Elements": elements, + } + ) + else: + # Single-element port + direction = ( + "Z" if port.geometry == PortGeometry.VIA else port.direction.upper() + ) + lumped_ports.append( + { + "Index": port_idx, + "R": port.impedance, + "Direction": direction, + "Excitation": port_idx if port.excited else False, + "Attributes": [port_group["phys_group"]], + } + ) + port_idx += 1 + + boundaries: dict[str, object] = { + "Conductivity": conductors, + "LumpedPort": lumped_ports, + } + + if "absorbing" in groups["boundary_surfaces"]: + boundaries["Absorbing"] = { + "Attributes": [groups["boundary_surfaces"]["absorbing"]["phys_group"]], + "Order": 2, + } + + config["Boundaries"] = boundaries + + # Write config file + config_path = output_path / "config.json" + with config_path.open("w") as f: + json.dump(config, f, indent=4) + + # Write port information file + port_info_path = output_path / "port_information.json" + port_info_struct = {"ports": port_info, "unit": 1e-6, "name": model_name} + with port_info_path.open("w") as f: + json.dump(port_info_struct, f, indent=4) + + return config_path + + +def collect_mesh_stats() -> dict: + """Collect mesh statistics from gmsh after mesh generation. + + Must be called while gmsh is initialized and the mesh is generated. + + Returns: + Dict with mesh statistics including: + - bbox: Bounding box coordinates + - nodes: Number of nodes + - elements: Total element count + - tetrahedra: Tet count + - quality: Shape quality metrics (gamma) + - sicn: Signed Inverse Condition Number + - edge_length: Min/max edge lengths + - groups: Physical group info + """ + stats = {} + + # Get bounding box + try: + xmin, ymin, zmin, xmax, ymax, zmax = gmsh.model.getBoundingBox(-1, -1) + stats["bbox"] = { + "xmin": xmin, + "ymin": ymin, + "zmin": zmin, + "xmax": xmax, + "ymax": ymax, + "zmax": zmax, + } + except Exception: + pass + + # Get node count + try: + node_tags, _, _ = gmsh.model.mesh.getNodes() + stats["nodes"] = len(node_tags) + except Exception: + pass + + # Get element counts and collect tet tags for quality + tet_tags = [] + try: + element_types, element_tags, _ = gmsh.model.mesh.getElements() + total_elements = sum(len(tags) for tags in element_tags) + stats["elements"] = total_elements + + # Count tetrahedra (type 4) and save tags + for etype, tags in zip(element_types, element_tags): + if etype == 4: # 4-node tetrahedron + stats["tetrahedra"] = len(tags) + tet_tags = list(tags) + except Exception: + pass + + # Get mesh quality for tetrahedra + if tet_tags: + # Gamma: inscribed/circumscribed radius ratio (shape quality) + try: + qualities = gmsh.model.mesh.getElementQualities(tet_tags, "gamma") + if len(qualities) > 0: + stats["quality"] = { + "min": round(min(qualities), 3), + "max": round(max(qualities), 3), + "mean": round(sum(qualities) / len(qualities), 3), + } + except Exception: + pass + + # SICN: Signed Inverse Condition Number (negative = invalid element) + try: + sicn = gmsh.model.mesh.getElementQualities(tet_tags, "minSICN") + if len(sicn) > 0: + sicn_min = min(sicn) + invalid_count = sum(1 for s in sicn if s < 0) + stats["sicn"] = { + "min": round(sicn_min, 3), + "mean": round(sum(sicn) / len(sicn), 3), + "invalid": invalid_count, + } + except Exception: + pass + + # Edge lengths + try: + min_edges = gmsh.model.mesh.getElementQualities(tet_tags, "minEdge") + max_edges = gmsh.model.mesh.getElementQualities(tet_tags, "maxEdge") + if len(min_edges) > 0 and len(max_edges) > 0: + stats["edge_length"] = { + "min": round(min(min_edges), 3), + "max": round(max(max_edges), 3), + } + except Exception: + pass + + # Get physical groups with tags + try: + groups = {"volumes": [], "surfaces": []} + for dim, tag in gmsh.model.getPhysicalGroups(): + name = gmsh.model.getPhysicalName(dim, tag) + entry = {"name": name, "tag": tag} + if dim == 3: + groups["volumes"].append(entry) + elif dim == 2: + groups["surfaces"].append(entry) + stats["groups"] = groups + except Exception: + pass + + return stats + + +def write_config( + mesh_result, + stack: LayerStack, + ports: list[PalacePort], + driven_config: DrivenConfig | None = None, +) -> Path: + """Write Palace config.json from a MeshResult. + + Use this to generate config separately after mesh(). + + Args: + mesh_result: Result from generate_mesh(write_config=False) + stack: LayerStack for material properties + ports: List of PalacePort objects + driven_config: Optional DrivenConfig for frequency sweep settings + + Returns: + Path to the generated config.json + + Raises: + ValueError: If mesh_result has no groups data + + Example: + >>> result = sim.mesh(output_dir, write_config=False) + >>> config_path = write_config(result, stack, ports, driven_config) + """ + if not mesh_result.groups: + raise ValueError( + "MeshResult has no groups data. Was it generated with write_config=False?" + ) + + config_path = generate_palace_config( + groups=mesh_result.groups, + ports=ports, + port_info=mesh_result.port_info, + stack=stack, + output_path=mesh_result.output_dir, + model_name=mesh_result.model_name, + fmax=mesh_result.fmax, + driven_config=driven_config, + ) + + # Update the mesh_result with the config path + mesh_result.config_path = config_path + + return config_path + + +__all__ = [ + "generate_palace_config", + "collect_mesh_stats", + "write_config", +] diff --git a/src/gsim/palace/mesh/generator.py b/src/gsim/palace/mesh/generator.py index fe70f74..0d6c9d0 100644 --- a/src/gsim/palace/mesh/generator.py +++ b/src/gsim/palace/mesh/generator.py @@ -6,7 +6,6 @@ from __future__ import annotations -import json import logging import math from dataclasses import dataclass, field @@ -16,26 +15,28 @@ import gmsh from . import gmsh_utils +from .config_generator import ( + collect_mesh_stats, + generate_palace_config, + write_config, +) +from .geometry import ( + GeometryData, + add_dielectrics, + add_metals, + add_ports, + extract_geometry, +) +from .groups import assign_physical_groups if TYPE_CHECKING: + from gsim.common.stack import LayerStack from gsim.palace.models import DrivenConfig from gsim.palace.ports.config import PalacePort - from gsim.common.stack import LayerStack - -from gsim.palace.ports.config import PortGeometry logger = logging.getLogger(__name__) -@dataclass -class GeometryData: - """Container for geometry data extracted from component.""" - - polygons: list # List of (layer_num, pts_x, pts_y) tuples - bbox: tuple[float, float, float, float] # (xmin, ymin, xmax, ymax) - layer_bboxes: dict # layer_num -> (xmin, ymin, xmax, ymax) - - @dataclass class MeshResult: """Result from mesh generation.""" @@ -51,567 +52,6 @@ class MeshResult: fmax: float = 100e9 -def extract_geometry(component, stack: LayerStack) -> GeometryData: - """Extract polygon geometry from a gdsfactory component. - - Args: - component: gdsfactory Component - stack: LayerStack for layer mapping - - Returns: - GeometryData with polygons and bounding boxes - """ - polygons = [] - global_bbox = [math.inf, math.inf, -math.inf, -math.inf] - layer_bboxes = {} - - # Get polygons from component - polygons_by_index = component.get_polygons() - - # Build layer_index -> GDS tuple mapping - layout = component.kcl.layout - index_to_gds = {} - for layer_index in range(layout.layers()): - if layout.is_valid_layer(layer_index): - info = layout.get_info(layer_index) - index_to_gds[layer_index] = (info.layer, info.datatype) - - # Build GDS tuple -> layer number mapping - gds_to_layernum = {} - for layer_data in stack.layers.values(): - gds_tuple = tuple(layer_data.gds_layer) - gds_to_layernum[gds_tuple] = gds_tuple[0] - - # Convert polygons - for layer_index, polys in polygons_by_index.items(): - gds_tuple = index_to_gds.get(layer_index) - if gds_tuple is None: - continue - - layernum = gds_to_layernum.get(gds_tuple) - if layernum is None: - continue - - for poly in polys: - # Convert klayout polygon to lists (nm -> um) - points = list(poly.each_point_hull()) - if len(points) < 3: - continue - - pts_x = [pt.x / 1000.0 for pt in points] - pts_y = [pt.y / 1000.0 for pt in points] - - polygons.append((layernum, pts_x, pts_y)) - - # Update bounding boxes - xmin, xmax = min(pts_x), max(pts_x) - ymin, ymax = min(pts_y), max(pts_y) - - global_bbox[0] = min(global_bbox[0], xmin) - global_bbox[1] = min(global_bbox[1], ymin) - global_bbox[2] = max(global_bbox[2], xmax) - global_bbox[3] = max(global_bbox[3], ymax) - - if layernum not in layer_bboxes: - layer_bboxes[layernum] = [xmin, ymin, xmax, ymax] - else: - bbox = layer_bboxes[layernum] - bbox[0] = min(bbox[0], xmin) - bbox[1] = min(bbox[1], ymin) - bbox[2] = max(bbox[2], xmax) - bbox[3] = max(bbox[3], ymax) - - return GeometryData( - polygons=polygons, - bbox=(global_bbox[0], global_bbox[1], global_bbox[2], global_bbox[3]), - layer_bboxes=layer_bboxes, - ) - - -def _get_layer_info(stack: LayerStack, gds_layer: int) -> dict | None: - """Get layer info from stack by GDS layer number.""" - for name, layer in stack.layers.items(): - if layer.gds_layer[0] == gds_layer: - return { - "name": name, - "zmin": layer.zmin, - "zmax": layer.zmax, - "thickness": layer.zmax - layer.zmin, - "material": layer.material, - "type": layer.layer_type, - } - return None - - -def _add_metals( - kernel, - geometry: GeometryData, - stack: LayerStack, -) -> dict: - """Add metal and via geometries to gmsh. - - Creates extruded volumes for vias and shells (surfaces) for conductors. - - Returns: - Dict with layer_name -> list of (surface_tags_xy, surface_tags_z) for - conductors, or volume_tags for vias. - """ - # layer_name -> {"volumes": [], "surfaces_xy": [], "surfaces_z": []} - metal_tags = {} - - # Group polygons by layer - polygons_by_layer = {} - for layernum, pts_x, pts_y in geometry.polygons: - if layernum not in polygons_by_layer: - polygons_by_layer[layernum] = [] - polygons_by_layer[layernum].append((pts_x, pts_y)) - - # Process each layer - for layernum, polys in polygons_by_layer.items(): - layer_info = _get_layer_info(stack, layernum) - if layer_info is None: - continue - - layer_name = layer_info["name"] - layer_type = layer_info["type"] - zmin = layer_info["zmin"] - thickness = layer_info["thickness"] - - if layer_type not in ("conductor", "via"): - continue - - if layer_name not in metal_tags: - metal_tags[layer_name] = { - "volumes": [], - "surfaces_xy": [], - "surfaces_z": [], - } - - for pts_x, pts_y in polys: - # Create extruded polygon - surfacetag = gmsh_utils.create_polygon_surface(kernel, pts_x, pts_y, zmin) - if surfacetag is None: - continue - - if thickness > 0: - result = kernel.extrude([(2, surfacetag)], 0, 0, thickness) - volumetag = result[1][1] - - if layer_type == "via": - # Keep vias as volumes - metal_tags[layer_name]["volumes"].append(volumetag) - else: - # For conductors, get shell surfaces and remove volume - _, surfaceloops = kernel.getSurfaceLoops(volumetag) - if surfaceloops: - metal_tags[layer_name]["volumes"].append( - (volumetag, surfaceloops[0]) - ) - kernel.remove([(3, volumetag)]) - - kernel.removeAllDuplicates() - kernel.synchronize() - - return metal_tags - - -def _add_dielectrics( - kernel, - geometry: GeometryData, - stack: LayerStack, - margin: float, - air_margin: float, -) -> dict: - """Add dielectric boxes and airbox to gmsh. - - Returns: - Dict with material_name -> list of volume_tags - """ - dielectric_tags = {} - - # Get overall geometry bounds - xmin, ymin, xmax, ymax = geometry.bbox - xmin -= margin - ymin -= margin - xmax += margin - ymax += margin - - # Track overall z range - z_min_all = math.inf - z_max_all = -math.inf - - # Sort dielectrics by z (top to bottom for correct layering) - sorted_dielectrics = sorted( - stack.dielectrics, key=lambda d: d["zmax"], reverse=True - ) - - # Add dielectric boxes - offset = 0 - offset_delta = margin / 20 - - for dielectric in sorted_dielectrics: - material = dielectric["material"] - d_zmin = dielectric["zmin"] - d_zmax = dielectric["zmax"] - - z_min_all = min(z_min_all, d_zmin) - z_max_all = max(z_max_all, d_zmax) - - if material not in dielectric_tags: - dielectric_tags[material] = [] - - # Create box with slight offset to avoid mesh issues - box_tag = gmsh_utils.create_box( - kernel, - xmin - offset, - ymin - offset, - d_zmin, - xmax + offset, - ymax + offset, - d_zmax, - ) - dielectric_tags[material].append(box_tag) - - # Alternate offset to avoid coincident faces - offset = offset_delta if offset == 0 else 0 - - # Add surrounding airbox - air_xmin = xmin - air_margin - air_ymin = ymin - air_margin - air_xmax = xmax + air_margin - air_ymax = ymax + air_margin - air_zmin = z_min_all - air_margin - air_zmax = z_max_all + air_margin - - airbox_tag = kernel.addBox( - air_xmin, - air_ymin, - air_zmin, - air_xmax - air_xmin, - air_ymax - air_ymin, - air_zmax - air_zmin, - ) - dielectric_tags["airbox"] = [airbox_tag] - - kernel.synchronize() - - return dielectric_tags - - -def _add_ports( - kernel, - ports: list[PalacePort], - stack: LayerStack, -) -> tuple[dict, list]: - """Add port surfaces to gmsh. - - Args: - kernel: gmsh kernel - ports: List of PalacePort objects (single or multi-element) - stack: Layer stack - - Returns: - (port_tags dict, port_info list) - - For single-element ports: port_tags["P{num}"] = [surface_tag] - For multi-element ports: port_tags["P{num}"] = [surface_tag, surface_tag, ...] - """ - port_tags = {} # "P{num}" -> [surface_tag(s)] - port_info = [] - port_num = 1 - - for port in ports: - if port.multi_element: - # Multi-element port (CPW) - if port.layer is None or port.centers is None or port.directions is None: - continue - target_layer = stack.layers.get(port.layer) - if target_layer is None: - continue - - z = target_layer.zmin - hw = port.width / 2 - hl = (port.length or port.width) / 2 - - # Determine axis from orientation - angle = port.orientation % 360 - is_y_axis = 45 <= angle < 135 or 225 <= angle < 315 - - surfaces = [] - for cx, cy in port.centers: - if is_y_axis: - surf = gmsh_utils.create_port_rectangle( - kernel, cx - hw, cy - hl, z, cx + hw, cy + hl, z - ) - else: - surf = gmsh_utils.create_port_rectangle( - kernel, cx - hl, cy - hw, z, cx + hl, cy + hw, z - ) - surfaces.append(surf) - - port_tags[f"P{port_num}"] = surfaces - - port_info.append( - { - "portnumber": port_num, - "Z0": port.impedance, - "type": "cpw", - "elements": [ - {"surface_idx": i, "direction": port.directions[i]} - for i in range(len(port.centers)) - ], - "width": port.width, - "length": port.length or port.width, - "zmin": z, - "zmax": z, - } - ) - - elif port.geometry == PortGeometry.VIA: - # Via port: vertical between two layers - if port.from_layer is None or port.to_layer is None: - continue - from_layer = stack.layers.get(port.from_layer) - to_layer = stack.layers.get(port.to_layer) - if from_layer is None or to_layer is None: - continue - - x, y = port.center - hw = port.width / 2 - - if from_layer.zmin < to_layer.zmin: - zmin = from_layer.zmax - zmax = to_layer.zmin - else: - zmin = to_layer.zmax - zmax = from_layer.zmin - - # Create vertical port surface - if port.direction in ("x", "-x"): - surfacetag = gmsh_utils.create_port_rectangle( - kernel, x, y - hw, zmin, x, y + hw, zmax - ) - else: - surfacetag = gmsh_utils.create_port_rectangle( - kernel, x - hw, y, zmin, x + hw, y, zmax - ) - - port_tags[f"P{port_num}"] = [surfacetag] - port_info.append( - { - "portnumber": port_num, - "Z0": port.impedance, - "type": "via", - "direction": "Z", - "length": zmax - zmin, - "width": port.width, - "xmin": x - hw if port.direction in ("y", "-y") else x, - "xmax": x + hw if port.direction in ("y", "-y") else x, - "ymin": y - hw if port.direction in ("x", "-x") else y, - "ymax": y + hw if port.direction in ("x", "-x") else y, - "zmin": zmin, - "zmax": zmax, - } - ) - - else: - # Inplane port: horizontal on single layer - if port.layer is None: - continue - target_layer = stack.layers.get(port.layer) - if target_layer is None: - continue - - x, y = port.center - hw = port.width / 2 - z = target_layer.zmin - - hl = (port.length or port.width) / 2 - if port.direction in ("x", "-x"): - surfacetag = gmsh_utils.create_port_rectangle( - kernel, x - hl, y - hw, z, x + hl, y + hw, z - ) - length = 2 * hl - width = port.width - else: - surfacetag = gmsh_utils.create_port_rectangle( - kernel, x - hw, y - hl, z, x + hw, y + hl, z - ) - length = port.width - width = 2 * hl - - port_tags[f"P{port_num}"] = [surfacetag] - port_info.append( - { - "portnumber": port_num, - "Z0": port.impedance, - "type": "lumped", - "direction": port.direction.upper(), - "length": length, - "width": width, - "xmin": x - hl if port.direction in ("x", "-x") else x - hw, - "xmax": x + hl if port.direction in ("x", "-x") else x + hw, - "ymin": y - hw if port.direction in ("x", "-x") else y - hl, - "ymax": y + hw if port.direction in ("x", "-x") else y + hl, - "zmin": z, - "zmax": z, - } - ) - - port_num += 1 - - kernel.synchronize() - - return port_tags, port_info - - -def _assign_physical_groups( - kernel, - metal_tags: dict, - dielectric_tags: dict, - port_tags: dict, - port_info: list, - geom_dimtags: list, - geom_map: list, - _stack: LayerStack, -) -> dict: - """Assign physical groups after fragmenting. - - Args: - kernel: gmsh kernel - metal_tags: Metal layer tags - dielectric_tags: Dielectric material tags - port_tags: Port surface tags (may have multiple surfaces for CPW) - port_info: Port metadata including type info - geom_dimtags: Dimension tags from fragmentation - geom_map: Geometry map from fragmentation - _stack: Layer stack (unused; reserved for future material metadata) - - Returns: - Dict with group info for config file generation - """ - groups = { - "volumes": {}, - "conductor_surfaces": {}, - "port_surfaces": {}, - "boundary_surfaces": {}, - } - - # Assign volume groups for dielectrics - for material_name, tags in dielectric_tags.items(): - new_tags = gmsh_utils.get_tags_after_fragment( - tags, geom_dimtags, geom_map, dimension=3 - ) - if new_tags: - # Only take first N tags (same as original count) - new_tags = new_tags[: len(tags)] - phys_group = gmsh_utils.assign_physical_group(3, new_tags, material_name) - groups["volumes"][material_name] = { - "phys_group": phys_group, - "tags": new_tags, - } - - # Assign surface groups for conductors - for layer_name, tag_info in metal_tags.items(): - if tag_info["volumes"]: - all_xy_tags = [] - all_z_tags = [] - - for item in tag_info["volumes"]: - if isinstance(item, tuple): - _volumetag, surface_tags = item - # Get updated surface tags after fragment - new_surface_tags = gmsh_utils.get_tags_after_fragment( - surface_tags, geom_dimtags, geom_map, dimension=2 - ) - - # Separate xy and z surfaces - for tag in new_surface_tags: - if gmsh_utils.is_vertical_surface(tag): - all_z_tags.append(tag) - else: - all_xy_tags.append(tag) - - if all_xy_tags: - phys_group = gmsh_utils.assign_physical_group( - 2, all_xy_tags, f"{layer_name}_xy" - ) - groups["conductor_surfaces"][f"{layer_name}_xy"] = { - "phys_group": phys_group, - "tags": all_xy_tags, - } - - if all_z_tags: - phys_group = gmsh_utils.assign_physical_group( - 2, all_z_tags, f"{layer_name}_z" - ) - groups["conductor_surfaces"][f"{layer_name}_z"] = { - "phys_group": phys_group, - "tags": all_z_tags, - } - - # Assign port surface groups - for port_name, tags in port_tags.items(): - # Find corresponding port_info entry - port_num = int(port_name[1:]) # "P1" -> 1 - info = next((p for p in port_info if p["portnumber"] == port_num), None) - - if info and info.get("type") == "cpw": - # CPW port: create separate physical group for each element - element_phys_groups = [] - for i, tag in enumerate(tags): - new_tag_list = gmsh_utils.get_tags_after_fragment( - [tag], geom_dimtags, geom_map, dimension=2 - ) - if new_tag_list: - elem_name = f"{port_name}_E{i}" - phys_group = gmsh_utils.assign_physical_group( - 2, new_tag_list, elem_name - ) - element_phys_groups.append( - { - "phys_group": phys_group, - "tags": new_tag_list, - "direction": info["elements"][i]["direction"], - } - ) - - groups["port_surfaces"][port_name] = { - "type": "cpw", - "elements": element_phys_groups, - } - else: - # Regular single-element port - new_tags = gmsh_utils.get_tags_after_fragment( - tags, geom_dimtags, geom_map, dimension=2 - ) - if new_tags: - phys_group = gmsh_utils.assign_physical_group(2, new_tags, port_name) - groups["port_surfaces"][port_name] = { - "phys_group": phys_group, - "tags": new_tags, - } - - # Assign boundary surfaces (from airbox) - if "airbox" in groups["volumes"]: - airbox_tags = groups["volumes"]["airbox"]["tags"] - if airbox_tags: - _, simulation_boundary = kernel.getSurfaceLoops(airbox_tags[0]) - if simulation_boundary: - boundary_tags = list(next(iter(simulation_boundary))) - phys_group = gmsh_utils.assign_physical_group( - 2, boundary_tags, "Absorbing_boundary" - ) - groups["boundary_surfaces"]["absorbing"] = { - "phys_group": phys_group, - "tags": boundary_tags, - } - - kernel.synchronize() - - return groups - - def _setup_mesh_fields( kernel, groups: dict, @@ -620,7 +60,16 @@ def _setup_mesh_fields( refined_cellsize: float, max_cellsize: float, ) -> None: - """Set up mesh refinement fields.""" + """Set up mesh refinement fields. + + Args: + kernel: gmsh OCC kernel + groups: Physical group information + geometry: Extracted geometry data + stack: LayerStack with material properties + refined_cellsize: Fine mesh size near conductors (um) + max_cellsize: Coarse mesh size in air/dielectric (um) + """ # Collect boundary lines from conductor surfaces boundary_lines = [] for surface_info in groups["conductor_surfaces"].values(): @@ -679,282 +128,6 @@ def _setup_mesh_fields( gmsh_utils.finalize_mesh_fields(field_ids) -def _generate_palace_config( - groups: dict, - ports: list[PalacePort], - port_info: list, - stack: LayerStack, - output_path: Path, - model_name: str, - fmax: float, - driven_config: DrivenConfig | None = None, -) -> Path: - """Generate Palace config.json file. - - Args: - groups: Physical group information from mesh generation - ports: List of PalacePort objects - port_info: Port metadata list - stack: Layer stack for material properties - output_path: Output directory path - model_name: Base name for output files - fmax: Maximum frequency (Hz) - used as fallback if driven_config not provided - driven_config: Optional DrivenConfig for frequency sweep settings - """ - # Use driven_config if provided, otherwise fall back to legacy parameters - if driven_config is not None: - solver_driven = driven_config.to_palace_config() - else: - # Legacy behavior - compute from fmax - freq_step = fmax / 40e9 - solver_driven = { - "Samples": [ - { - "Type": "Linear", - "MinFreq": 1.0, # 1 GHz - "MaxFreq": fmax / 1e9, - "FreqStep": freq_step, - "SaveStep": 0, - } - ], - "AdaptiveTol": 0.02, - } - - config: dict[str, object] = { - "Problem": { - "Type": "Driven", - "Verbose": 3, - "Output": f"output/{model_name}", - }, - "Model": { - "Mesh": f"{model_name}.msh", - "L0": 1e-6, # um - "Refinement": { - "UniformLevels": 0, - "Tol": 1e-2, - "MaxIts": 0, - }, - }, - "Solver": { - "Linear": { - "Type": "Default", - "KSPType": "GMRES", - "Tol": 1e-6, - "MaxIts": 400, - }, - "Order": 2, - "Device": "CPU", - "Driven": solver_driven, - }, - } - - # Build domains section - materials: list[dict[str, object]] = [] - for material_name, info in groups["volumes"].items(): - mat_props = stack.materials.get(material_name, {}) - mat_entry: dict[str, object] = {"Attributes": [info["phys_group"]]} - - if material_name == "airbox": - mat_entry["Permittivity"] = 1.0 - mat_entry["LossTan"] = 0.0 - else: - mat_entry["Permittivity"] = mat_props.get("permittivity", 1.0) - sigma = mat_props.get("conductivity", 0.0) - if sigma > 0: - mat_entry["Conductivity"] = sigma - else: - mat_entry["LossTan"] = mat_props.get("loss_tangent", 0.0) - - materials.append(mat_entry) - - config["Domains"] = { - "Materials": materials, - "Postprocessing": {"Energy": [], "Probe": []}, - } - - # Build boundaries section - conductors: list[dict[str, object]] = [] - for name, info in groups["conductor_surfaces"].items(): - # Extract layer name from "layer_xy" or "layer_z" - layer_name = name.rsplit("_", 1)[0] - layer = stack.layers.get(layer_name) - if layer: - mat_props = stack.materials.get(layer.material, {}) - conductors.append( - { - "Attributes": [info["phys_group"]], - "Conductivity": mat_props.get("conductivity", 5.8e7), - "Thickness": layer.zmax - layer.zmin, - } - ) - - lumped_ports: list[dict[str, object]] = [] - port_idx = 1 - - for port in ports: - port_key = f"P{port_idx}" - if port_key in groups["port_surfaces"]: - port_group = groups["port_surfaces"][port_key] - - if port.multi_element: - # Multi-element port (CPW) - if port_group.get("type") == "cpw": - elements = [ - { - "Attributes": [elem["phys_group"]], - "Direction": elem["direction"], - } - for elem in port_group["elements"] - ] - - lumped_ports.append( - { - "Index": port_idx, - "R": port.impedance, - "Excitation": port_idx if port.excited else False, - "Elements": elements, - } - ) - else: - # Single-element port - direction = ( - "Z" if port.geometry == PortGeometry.VIA else port.direction.upper() - ) - lumped_ports.append( - { - "Index": port_idx, - "R": port.impedance, - "Direction": direction, - "Excitation": port_idx if port.excited else False, - "Attributes": [port_group["phys_group"]], - } - ) - port_idx += 1 - - boundaries: dict[str, object] = { - "Conductivity": conductors, - "LumpedPort": lumped_ports, - } - - if "absorbing" in groups["boundary_surfaces"]: - boundaries["Absorbing"] = { - "Attributes": [groups["boundary_surfaces"]["absorbing"]["phys_group"]], - "Order": 2, - } - - config["Boundaries"] = boundaries - - # Write config file - config_path = output_path / "config.json" - with config_path.open("w") as f: - json.dump(config, f, indent=4) - - # Write port information file - port_info_path = output_path / "port_information.json" - port_info_struct = {"ports": port_info, "unit": 1e-6, "name": model_name} - with port_info_path.open("w") as f: - json.dump(port_info_struct, f, indent=4) - - return config_path - - -def _collect_mesh_stats() -> dict: - """Collect mesh statistics from gmsh after mesh generation.""" - stats = {} - - # Get bounding box - try: - xmin, ymin, zmin, xmax, ymax, zmax = gmsh.model.getBoundingBox(-1, -1) - stats["bbox"] = { - "xmin": xmin, - "ymin": ymin, - "zmin": zmin, - "xmax": xmax, - "ymax": ymax, - "zmax": zmax, - } - except Exception: - pass - - # Get node count - try: - node_tags, _, _ = gmsh.model.mesh.getNodes() - stats["nodes"] = len(node_tags) - except Exception: - pass - - # Get element counts and collect tet tags for quality - tet_tags = [] - try: - element_types, element_tags, _ = gmsh.model.mesh.getElements() - total_elements = sum(len(tags) for tags in element_tags) - stats["elements"] = total_elements - - # Count tetrahedra (type 4) and save tags - for etype, tags in zip(element_types, element_tags): - if etype == 4: # 4-node tetrahedron - stats["tetrahedra"] = len(tags) - tet_tags = list(tags) - except Exception: - pass - - # Get mesh quality for tetrahedra - if tet_tags: - # Gamma: inscribed/circumscribed radius ratio (shape quality) - try: - qualities = gmsh.model.mesh.getElementQualities(tet_tags, "gamma") - if len(qualities) > 0: - stats["quality"] = { - "min": round(min(qualities), 3), - "max": round(max(qualities), 3), - "mean": round(sum(qualities) / len(qualities), 3), - } - except Exception: - pass - - # SICN: Signed Inverse Condition Number (negative = invalid element) - try: - sicn = gmsh.model.mesh.getElementQualities(tet_tags, "minSICN") - if len(sicn) > 0: - sicn_min = min(sicn) - invalid_count = sum(1 for s in sicn if s < 0) - stats["sicn"] = { - "min": round(sicn_min, 3), - "mean": round(sum(sicn) / len(sicn), 3), - "invalid": invalid_count, - } - except Exception: - pass - - # Edge lengths - try: - min_edges = gmsh.model.mesh.getElementQualities(tet_tags, "minEdge") - max_edges = gmsh.model.mesh.getElementQualities(tet_tags, "maxEdge") - if len(min_edges) > 0 and len(max_edges) > 0: - stats["edge_length"] = { - "min": round(min(min_edges), 3), - "max": round(max(max_edges), 3), - } - except Exception: - pass - - # Get physical groups with tags - try: - groups = {"volumes": [], "surfaces": []} - for dim, tag in gmsh.model.getPhysicalGroups(): - name = gmsh.model.getPhysicalName(dim, tag) - entry = {"name": name, "tag": tag} - if dim == 3: - groups["volumes"].append(entry) - elif dim == 2: - groups["surfaces"].append(entry) - stats["groups"] = groups - except Exception: - pass - - return stats - - def generate_mesh( component, stack: LayerStack, @@ -1017,13 +190,13 @@ def generate_mesh( try: # Add geometry logger.info("Adding metals...") - metal_tags = _add_metals(kernel, geometry, stack) + metal_tags = add_metals(kernel, geometry, stack) logger.info("Adding ports...") - port_tags, port_info = _add_ports(kernel, ports, stack) + port_tags, port_info = add_ports(kernel, ports, stack) logger.info("Adding dielectrics...") - dielectric_tags = _add_dielectrics(kernel, geometry, stack, margin, air_margin) + dielectric_tags = add_dielectrics(kernel, geometry, stack, margin, air_margin) # Fragment geometry logger.info("Fragmenting geometry...") @@ -1031,7 +204,7 @@ def generate_mesh( # Assign physical groups logger.info("Assigning physical groups...") - groups = _assign_physical_groups( + groups = assign_physical_groups( kernel, metal_tags, dielectric_tags, @@ -1057,7 +230,7 @@ def generate_mesh( gmsh.model.mesh.generate(3) # Collect mesh statistics - mesh_stats = _collect_mesh_stats() + mesh_stats = collect_mesh_stats() # Save mesh gmsh.option.setNumber("Mesh.Binary", 0) @@ -1071,7 +244,7 @@ def generate_mesh( config_path = None if write_config: logger.info("Generating Palace config...") - config_path = _generate_palace_config( + config_path = generate_palace_config( groups, ports, port_info, stack, output_dir, model_name, fmax, driven_config ) @@ -1094,44 +267,5 @@ def generate_mesh( return result -def write_config( - mesh_result: MeshResult, - stack: LayerStack, - ports: list[PalacePort], - driven_config: DrivenConfig | None = None, -) -> Path: - """Write Palace config.json from a MeshResult. - - Use this to generate config separately after mesh(). - - Args: - mesh_result: Result from generate_mesh(write_config=False) - stack: LayerStack for material properties - ports: List of PalacePort objects - driven_config: Optional DrivenConfig for frequency sweep settings - - Returns: - Path to the generated config.json - - Example: - >>> result = sim.mesh(output_dir, write_config=False) - >>> config_path = write_config(result, stack, ports, driven_config) - """ - if not mesh_result.groups: - raise ValueError("MeshResult has no groups data. Was it generated with write_config=False?") - - config_path = _generate_palace_config( - groups=mesh_result.groups, - ports=ports, - port_info=mesh_result.port_info, - stack=stack, - output_path=mesh_result.output_dir, - model_name=mesh_result.model_name, - fmax=mesh_result.fmax, - driven_config=driven_config, - ) - - # Update the mesh_result with the config path - mesh_result.config_path = config_path - - return config_path +# Re-export write_config from config_generator for backward compatibility +__all__ = ["MeshResult", "GeometryData", "generate_mesh", "write_config"] diff --git a/src/gsim/palace/mesh/geometry.py b/src/gsim/palace/mesh/geometry.py new file mode 100644 index 0000000..22d9005 --- /dev/null +++ b/src/gsim/palace/mesh/geometry.py @@ -0,0 +1,472 @@ +"""Geometry extraction and creation for Palace mesh generation. + +This module handles extracting polygons from gdsfactory components +and creating 3D geometry in gmsh. +""" + +from __future__ import annotations + +import math +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from . import gmsh_utils + +if TYPE_CHECKING: + from gsim.common.stack import LayerStack + from gsim.palace.ports.config import PalacePort + + +@dataclass +class GeometryData: + """Container for geometry data extracted from component.""" + + polygons: list # List of (layer_num, pts_x, pts_y) tuples + bbox: tuple[float, float, float, float] # (xmin, ymin, xmax, ymax) + layer_bboxes: dict # layer_num -> (xmin, ymin, xmax, ymax) + + +def extract_geometry(component, stack: LayerStack) -> GeometryData: + """Extract polygon geometry from a gdsfactory component. + + Args: + component: gdsfactory Component + stack: LayerStack for layer mapping + + Returns: + GeometryData with polygons and bounding boxes + """ + polygons = [] + global_bbox = [math.inf, math.inf, -math.inf, -math.inf] + layer_bboxes = {} + + # Get polygons from component + polygons_by_index = component.get_polygons() + + # Build layer_index -> GDS tuple mapping + layout = component.kcl.layout + index_to_gds = {} + for layer_index in range(layout.layers()): + if layout.is_valid_layer(layer_index): + info = layout.get_info(layer_index) + index_to_gds[layer_index] = (info.layer, info.datatype) + + # Build GDS tuple -> layer number mapping + gds_to_layernum = {} + for layer_data in stack.layers.values(): + gds_tuple = tuple(layer_data.gds_layer) + gds_to_layernum[gds_tuple] = gds_tuple[0] + + # Convert polygons + for layer_index, polys in polygons_by_index.items(): + gds_tuple = index_to_gds.get(layer_index) + if gds_tuple is None: + continue + + layernum = gds_to_layernum.get(gds_tuple) + if layernum is None: + continue + + for poly in polys: + # Convert klayout polygon to lists (nm -> um) + points = list(poly.each_point_hull()) + if len(points) < 3: + continue + + pts_x = [pt.x / 1000.0 for pt in points] + pts_y = [pt.y / 1000.0 for pt in points] + + polygons.append((layernum, pts_x, pts_y)) + + # Update bounding boxes + xmin, xmax = min(pts_x), max(pts_x) + ymin, ymax = min(pts_y), max(pts_y) + + global_bbox[0] = min(global_bbox[0], xmin) + global_bbox[1] = min(global_bbox[1], ymin) + global_bbox[2] = max(global_bbox[2], xmax) + global_bbox[3] = max(global_bbox[3], ymax) + + if layernum not in layer_bboxes: + layer_bboxes[layernum] = [xmin, ymin, xmax, ymax] + else: + bbox = layer_bboxes[layernum] + bbox[0] = min(bbox[0], xmin) + bbox[1] = min(bbox[1], ymin) + bbox[2] = max(bbox[2], xmax) + bbox[3] = max(bbox[3], ymax) + + return GeometryData( + polygons=polygons, + bbox=(global_bbox[0], global_bbox[1], global_bbox[2], global_bbox[3]), + layer_bboxes=layer_bboxes, + ) + + +def get_layer_info(stack: LayerStack, gds_layer: int) -> dict | None: + """Get layer info from stack by GDS layer number. + + Args: + stack: LayerStack with layer definitions + gds_layer: GDS layer number + + Returns: + Dict with layer info or None if not found + """ + for name, layer in stack.layers.items(): + if layer.gds_layer[0] == gds_layer: + return { + "name": name, + "zmin": layer.zmin, + "zmax": layer.zmax, + "thickness": layer.zmax - layer.zmin, + "material": layer.material, + "type": layer.layer_type, + } + return None + + +def add_metals( + kernel, + geometry: GeometryData, + stack: LayerStack, +) -> dict: + """Add metal and via geometries to gmsh. + + Creates extruded volumes for vias and shells (surfaces) for conductors. + + Args: + kernel: gmsh OCC kernel + geometry: Extracted geometry data + stack: LayerStack with layer definitions + + Returns: + Dict with layer_name -> list of (surface_tags_xy, surface_tags_z) for + conductors, or volume_tags for vias. + """ + # layer_name -> {"volumes": [], "surfaces_xy": [], "surfaces_z": []} + metal_tags = {} + + # Group polygons by layer + polygons_by_layer = {} + for layernum, pts_x, pts_y in geometry.polygons: + if layernum not in polygons_by_layer: + polygons_by_layer[layernum] = [] + polygons_by_layer[layernum].append((pts_x, pts_y)) + + # Process each layer + for layernum, polys in polygons_by_layer.items(): + layer_info = get_layer_info(stack, layernum) + if layer_info is None: + continue + + layer_name = layer_info["name"] + layer_type = layer_info["type"] + zmin = layer_info["zmin"] + thickness = layer_info["thickness"] + + if layer_type not in ("conductor", "via"): + continue + + if layer_name not in metal_tags: + metal_tags[layer_name] = { + "volumes": [], + "surfaces_xy": [], + "surfaces_z": [], + } + + for pts_x, pts_y in polys: + # Create extruded polygon + surfacetag = gmsh_utils.create_polygon_surface(kernel, pts_x, pts_y, zmin) + if surfacetag is None: + continue + + if thickness > 0: + result = kernel.extrude([(2, surfacetag)], 0, 0, thickness) + volumetag = result[1][1] + + if layer_type == "via": + # Keep vias as volumes + metal_tags[layer_name]["volumes"].append(volumetag) + else: + # For conductors, get shell surfaces and remove volume + _, surfaceloops = kernel.getSurfaceLoops(volumetag) + if surfaceloops: + metal_tags[layer_name]["volumes"].append( + (volumetag, surfaceloops[0]) + ) + kernel.remove([(3, volumetag)]) + + kernel.removeAllDuplicates() + kernel.synchronize() + + return metal_tags + + +def add_dielectrics( + kernel, + geometry: GeometryData, + stack: LayerStack, + margin: float, + air_margin: float, +) -> dict: + """Add dielectric boxes and airbox to gmsh. + + Args: + kernel: gmsh OCC kernel + geometry: Extracted geometry data + stack: LayerStack with dielectric definitions + margin: XY margin around design (um) + air_margin: Air box margin (um) + + Returns: + Dict with material_name -> list of volume_tags + """ + dielectric_tags = {} + + # Get overall geometry bounds + xmin, ymin, xmax, ymax = geometry.bbox + xmin -= margin + ymin -= margin + xmax += margin + ymax += margin + + # Track overall z range + z_min_all = math.inf + z_max_all = -math.inf + + # Sort dielectrics by z (top to bottom for correct layering) + sorted_dielectrics = sorted( + stack.dielectrics, key=lambda d: d["zmax"], reverse=True + ) + + # Add dielectric boxes + offset = 0 + offset_delta = margin / 20 + + for dielectric in sorted_dielectrics: + material = dielectric["material"] + d_zmin = dielectric["zmin"] + d_zmax = dielectric["zmax"] + + z_min_all = min(z_min_all, d_zmin) + z_max_all = max(z_max_all, d_zmax) + + if material not in dielectric_tags: + dielectric_tags[material] = [] + + # Create box with slight offset to avoid mesh issues + box_tag = gmsh_utils.create_box( + kernel, + xmin - offset, + ymin - offset, + d_zmin, + xmax + offset, + ymax + offset, + d_zmax, + ) + dielectric_tags[material].append(box_tag) + + # Alternate offset to avoid coincident faces + offset = offset_delta if offset == 0 else 0 + + # Add surrounding airbox + air_xmin = xmin - air_margin + air_ymin = ymin - air_margin + air_xmax = xmax + air_margin + air_ymax = ymax + air_margin + air_zmin = z_min_all - air_margin + air_zmax = z_max_all + air_margin + + airbox_tag = kernel.addBox( + air_xmin, + air_ymin, + air_zmin, + air_xmax - air_xmin, + air_ymax - air_ymin, + air_zmax - air_zmin, + ) + dielectric_tags["airbox"] = [airbox_tag] + + kernel.synchronize() + + return dielectric_tags + + +def add_ports( + kernel, + ports: list[PalacePort], + stack: LayerStack, +) -> tuple[dict, list]: + """Add port surfaces to gmsh. + + Args: + kernel: gmsh OCC kernel + ports: List of PalacePort objects (single or multi-element) + stack: Layer stack + + Returns: + (port_tags dict, port_info list) + + For single-element ports: port_tags["P{num}"] = [surface_tag] + For multi-element ports: port_tags["P{num}"] = [surface_tag, surface_tag, ...] + """ + from gsim.palace.ports.config import PortGeometry + + port_tags = {} # "P{num}" -> [surface_tag(s)] + port_info = [] + port_num = 1 + + for port in ports: + if port.multi_element: + # Multi-element port (CPW) + if port.layer is None or port.centers is None or port.directions is None: + continue + target_layer = stack.layers.get(port.layer) + if target_layer is None: + continue + + z = target_layer.zmin + hw = port.width / 2 + hl = (port.length or port.width) / 2 + + # Determine axis from orientation + angle = port.orientation % 360 + is_y_axis = 45 <= angle < 135 or 225 <= angle < 315 + + surfaces = [] + for cx, cy in port.centers: + if is_y_axis: + surf = gmsh_utils.create_port_rectangle( + kernel, cx - hw, cy - hl, z, cx + hw, cy + hl, z + ) + else: + surf = gmsh_utils.create_port_rectangle( + kernel, cx - hl, cy - hw, z, cx + hl, cy + hw, z + ) + surfaces.append(surf) + + port_tags[f"P{port_num}"] = surfaces + + port_info.append( + { + "portnumber": port_num, + "Z0": port.impedance, + "type": "cpw", + "elements": [ + {"surface_idx": i, "direction": port.directions[i]} + for i in range(len(port.centers)) + ], + "width": port.width, + "length": port.length or port.width, + "zmin": z, + "zmax": z, + } + ) + + elif port.geometry == PortGeometry.VIA: + # Via port: vertical between two layers + if port.from_layer is None or port.to_layer is None: + continue + from_layer = stack.layers.get(port.from_layer) + to_layer = stack.layers.get(port.to_layer) + if from_layer is None or to_layer is None: + continue + + x, y = port.center + hw = port.width / 2 + + if from_layer.zmin < to_layer.zmin: + zmin = from_layer.zmax + zmax = to_layer.zmin + else: + zmin = to_layer.zmax + zmax = from_layer.zmin + + # Create vertical port surface + if port.direction in ("x", "-x"): + surfacetag = gmsh_utils.create_port_rectangle( + kernel, x, y - hw, zmin, x, y + hw, zmax + ) + else: + surfacetag = gmsh_utils.create_port_rectangle( + kernel, x - hw, y, zmin, x + hw, y, zmax + ) + + port_tags[f"P{port_num}"] = [surfacetag] + port_info.append( + { + "portnumber": port_num, + "Z0": port.impedance, + "type": "via", + "direction": "Z", + "length": zmax - zmin, + "width": port.width, + "xmin": x - hw if port.direction in ("y", "-y") else x, + "xmax": x + hw if port.direction in ("y", "-y") else x, + "ymin": y - hw if port.direction in ("x", "-x") else y, + "ymax": y + hw if port.direction in ("x", "-x") else y, + "zmin": zmin, + "zmax": zmax, + } + ) + + else: + # Inplane port: horizontal on single layer + if port.layer is None: + continue + target_layer = stack.layers.get(port.layer) + if target_layer is None: + continue + + x, y = port.center + hw = port.width / 2 + z = target_layer.zmin + + hl = (port.length or port.width) / 2 + if port.direction in ("x", "-x"): + surfacetag = gmsh_utils.create_port_rectangle( + kernel, x - hl, y - hw, z, x + hl, y + hw, z + ) + length = 2 * hl + width = port.width + else: + surfacetag = gmsh_utils.create_port_rectangle( + kernel, x - hw, y - hl, z, x + hw, y + hl, z + ) + length = port.width + width = 2 * hl + + port_tags[f"P{port_num}"] = [surfacetag] + port_info.append( + { + "portnumber": port_num, + "Z0": port.impedance, + "type": "lumped", + "direction": port.direction.upper(), + "length": length, + "width": width, + "xmin": x - hl if port.direction in ("x", "-x") else x - hw, + "xmax": x + hl if port.direction in ("x", "-x") else x + hw, + "ymin": y - hw if port.direction in ("x", "-x") else y - hl, + "ymax": y + hw if port.direction in ("x", "-x") else y + hl, + "zmin": z, + "zmax": z, + } + ) + + port_num += 1 + + kernel.synchronize() + + return port_tags, port_info + + +__all__ = [ + "GeometryData", + "extract_geometry", + "get_layer_info", + "add_metals", + "add_dielectrics", + "add_ports", +] diff --git a/src/gsim/palace/mesh/groups.py b/src/gsim/palace/mesh/groups.py new file mode 100644 index 0000000..256018e --- /dev/null +++ b/src/gsim/palace/mesh/groups.py @@ -0,0 +1,170 @@ +"""Physical group assignment for Palace mesh generation. + +This module handles assigning gmsh physical groups after geometry fragmentation. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from . import gmsh_utils + +if TYPE_CHECKING: + from gsim.common.stack import LayerStack + + +def assign_physical_groups( + kernel, + metal_tags: dict, + dielectric_tags: dict, + port_tags: dict, + port_info: list, + geom_dimtags: list, + geom_map: list, + _stack: LayerStack, +) -> dict: + """Assign physical groups after fragmenting. + + Args: + kernel: gmsh OCC kernel + metal_tags: Metal layer tags from add_metals() + dielectric_tags: Dielectric material tags from add_dielectrics() + port_tags: Port surface tags (may have multiple surfaces for CPW) + port_info: Port metadata including type info + geom_dimtags: Dimension tags from fragmentation + geom_map: Geometry map from fragmentation + _stack: Layer stack (unused; reserved for future material metadata) + + Returns: + Dict with group info for config file generation: + { + "volumes": {material_name: {"phys_group": int, "tags": [int]}}, + "conductor_surfaces": {layer_name: {"phys_group": int, "tags": [int]}}, + "port_surfaces": {port_name: {"phys_group": int, "tags": [int]} or + {"type": "cpw", "elements": [...]}}, + "boundary_surfaces": {"absorbing": {"phys_group": int, "tags": [int]}} + } + """ + groups = { + "volumes": {}, + "conductor_surfaces": {}, + "port_surfaces": {}, + "boundary_surfaces": {}, + } + + # Assign volume groups for dielectrics + for material_name, tags in dielectric_tags.items(): + new_tags = gmsh_utils.get_tags_after_fragment( + tags, geom_dimtags, geom_map, dimension=3 + ) + if new_tags: + # Only take first N tags (same as original count) + new_tags = new_tags[: len(tags)] + phys_group = gmsh_utils.assign_physical_group(3, new_tags, material_name) + groups["volumes"][material_name] = { + "phys_group": phys_group, + "tags": new_tags, + } + + # Assign surface groups for conductors + for layer_name, tag_info in metal_tags.items(): + if tag_info["volumes"]: + all_xy_tags = [] + all_z_tags = [] + + for item in tag_info["volumes"]: + if isinstance(item, tuple): + _volumetag, surface_tags = item + # Get updated surface tags after fragment + new_surface_tags = gmsh_utils.get_tags_after_fragment( + surface_tags, geom_dimtags, geom_map, dimension=2 + ) + + # Separate xy and z surfaces + for tag in new_surface_tags: + if gmsh_utils.is_vertical_surface(tag): + all_z_tags.append(tag) + else: + all_xy_tags.append(tag) + + if all_xy_tags: + phys_group = gmsh_utils.assign_physical_group( + 2, all_xy_tags, f"{layer_name}_xy" + ) + groups["conductor_surfaces"][f"{layer_name}_xy"] = { + "phys_group": phys_group, + "tags": all_xy_tags, + } + + if all_z_tags: + phys_group = gmsh_utils.assign_physical_group( + 2, all_z_tags, f"{layer_name}_z" + ) + groups["conductor_surfaces"][f"{layer_name}_z"] = { + "phys_group": phys_group, + "tags": all_z_tags, + } + + # Assign port surface groups + for port_name, tags in port_tags.items(): + # Find corresponding port_info entry + port_num = int(port_name[1:]) # "P1" -> 1 + info = next((p for p in port_info if p["portnumber"] == port_num), None) + + if info and info.get("type") == "cpw": + # CPW port: create separate physical group for each element + element_phys_groups = [] + for i, tag in enumerate(tags): + new_tag_list = gmsh_utils.get_tags_after_fragment( + [tag], geom_dimtags, geom_map, dimension=2 + ) + if new_tag_list: + elem_name = f"{port_name}_E{i}" + phys_group = gmsh_utils.assign_physical_group( + 2, new_tag_list, elem_name + ) + element_phys_groups.append( + { + "phys_group": phys_group, + "tags": new_tag_list, + "direction": info["elements"][i]["direction"], + } + ) + + groups["port_surfaces"][port_name] = { + "type": "cpw", + "elements": element_phys_groups, + } + else: + # Regular single-element port + new_tags = gmsh_utils.get_tags_after_fragment( + tags, geom_dimtags, geom_map, dimension=2 + ) + if new_tags: + phys_group = gmsh_utils.assign_physical_group(2, new_tags, port_name) + groups["port_surfaces"][port_name] = { + "phys_group": phys_group, + "tags": new_tags, + } + + # Assign boundary surfaces (from airbox) + if "airbox" in groups["volumes"]: + airbox_tags = groups["volumes"]["airbox"]["tags"] + if airbox_tags: + _, simulation_boundary = kernel.getSurfaceLoops(airbox_tags[0]) + if simulation_boundary: + boundary_tags = list(next(iter(simulation_boundary))) + phys_group = gmsh_utils.assign_physical_group( + 2, boundary_tags, "Absorbing_boundary" + ) + groups["boundary_surfaces"]["absorbing"] = { + "phys_group": phys_group, + "tags": boundary_tags, + } + + kernel.synchronize() + + return groups + + +__all__ = ["assign_physical_groups"] diff --git a/tests/palace/test_sim_classes.py b/tests/palace/test_sim_classes.py new file mode 100644 index 0000000..3dc3a97 --- /dev/null +++ b/tests/palace/test_sim_classes.py @@ -0,0 +1,152 @@ +"""Tests for Palace simulation classes.""" + +from __future__ import annotations + +import pytest + +from gsim.palace import DrivenSim, EigenmodeSim, ElectrostaticSim + + +class TestDrivenSimValidation: + """Test DrivenSim validation logic.""" + + def test_missing_geometry(self): + """Test validation catches missing geometry.""" + sim = DrivenSim() + result = sim.validate() + assert not result.valid + assert any("No component set" in e for e in result.errors) + + def test_inplane_port_requires_layer(self): + """Test that add_port raises for inplane port without layer.""" + sim = DrivenSim() + # PortConfig validates eagerly at creation time + with pytest.raises(Exception): # pydantic ValidationError + sim.add_port("o1", geometry="inplane") # No layer specified + + def test_via_port_requires_layers(self): + """Test that add_port raises for via port without layers.""" + sim = DrivenSim() + # PortConfig validates eagerly at creation time + with pytest.raises(Exception): # pydantic ValidationError + sim.add_port("o1", geometry="via") # No from_layer/to_layer + + def test_cpw_port_requires_layer(self): + """Test validation catches CPW port without layer.""" + sim = DrivenSim() + sim.add_cpw_port("P1", "P2", layer="", length=5.0) # Empty layer + result = sim.validate() + assert not result.valid + assert any("'layer' is required" in e for e in result.errors) + + def test_no_ports_warning(self): + """Test validation warns when no ports configured.""" + sim = DrivenSim() + result = sim.validate() + # Should have warning about no ports (but this is not an error) + assert any("No ports configured" in w for w in result.warnings) + + def test_invalid_excitation_port(self): + """Test validation catches invalid excitation port.""" + sim = DrivenSim() + sim.add_port("o1", layer="metal1", length=5.0) + sim.set_driven(excitation_port="nonexistent") + result = sim.validate() + assert not result.valid + assert any("Excitation port 'nonexistent' not found" in e for e in result.errors) + + +class TestEigenSimValidation: + """Test EigenmodeSim validation logic.""" + + def test_missing_geometry(self): + """Test validation catches missing geometry.""" + sim = EigenmodeSim() + result = sim.validate() + assert not result.valid + assert any("No component set" in e for e in result.errors) + + def test_no_ports_is_warning_not_error(self): + """Test that no ports is a warning, not an error for eigenmode.""" + sim = EigenmodeSim() + result = sim.validate() + # Eigenmode can work without ports (finds all modes) + assert any("No ports configured" in w for w in result.warnings) + + def test_inplane_port_requires_layer(self): + """Test that add_port raises for inplane port without layer.""" + sim = EigenmodeSim() + # PortConfig validates eagerly at creation time + with pytest.raises(Exception): # pydantic ValidationError + sim.add_port("o1", geometry="inplane") # No layer + + +class TestElectrostaticSimValidation: + """Test ElectrostaticSim validation logic.""" + + def test_missing_geometry(self): + """Test validation catches missing geometry.""" + sim = ElectrostaticSim() + result = sim.validate() + assert not result.valid + assert any("No component set" in e for e in result.errors) + + def test_requires_two_terminals(self): + """Test validation requires at least 2 terminals.""" + sim = ElectrostaticSim() + sim.add_terminal("T1", layer="metal1") # Only one terminal + result = sim.validate() + assert not result.valid + assert any("at least 2 terminals" in e for e in result.errors) + + def test_two_terminals_valid(self): + """Test validation passes with 2 terminals (but missing geometry).""" + sim = ElectrostaticSim() + sim.add_terminal("T1", layer="metal1") + sim.add_terminal("T2", layer="metal1") + result = sim.validate() + # Still invalid due to missing geometry, but terminal count is OK + assert any("No component set" in e for e in result.errors) + assert not any("at least 2 terminals" in e for e in result.errors) + + +class TestMixinMethods: + """Test mixin methods work on all simulation classes.""" + + def test_set_output_dir(self, tmp_path): + """Test set_output_dir works on all sim classes.""" + for cls in [DrivenSim, EigenmodeSim, ElectrostaticSim]: + sim = cls() + sim.set_output_dir(tmp_path / "test") + assert sim.output_dir == tmp_path / "test" + assert sim.output_dir.exists() + + def test_set_stack(self): + """Test set_stack works on all sim classes.""" + for cls in [DrivenSim, EigenmodeSim, ElectrostaticSim]: + sim = cls() + sim.set_stack(air_above=500.0) + assert sim._stack_kwargs["air_above"] == 500.0 + + def test_set_material(self): + """Test set_material works on all sim classes.""" + for cls in [DrivenSim, EigenmodeSim, ElectrostaticSim]: + sim = cls() + sim.set_material("custom_metal", type="conductor", conductivity=1e7) + assert "custom_metal" in sim.materials + assert sim.materials["custom_metal"].conductivity == 1e7 + + def test_set_numerical(self): + """Test set_numerical works on all sim classes.""" + for cls in [DrivenSim, EigenmodeSim, ElectrostaticSim]: + sim = cls() + sim.set_numerical(order=3, tolerance=1e-8) + assert sim.numerical.order == 3 + assert sim.numerical.tolerance == 1e-8 + + def test_mesh_requires_output_dir(self): + """Test mesh() raises if output_dir not set.""" + for cls in [DrivenSim, EigenmodeSim, ElectrostaticSim]: + sim = cls() + with pytest.raises(ValueError, match="Output directory not set"): + sim.mesh()