diff --git a/CHANGELOG.md b/CHANGELOG.md index 099b62f2fe..25829061d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added current integral specification classes: `AxisAlignedCurrentIntegralSpec`, `CompositeCurrentIntegralSpec`, and `Custom2DCurrentIntegralSpec`. - `sort_spec` in `ModeSpec` allows for fine-grained filtering and sorting of modes. This also deprecates `filter_pol`. The equivalent usage for example to `filter_pol="te"` is `sort_spec=ModeSortSpec(filter_key="TE_polarization", filter_reference=0.5)`. `ModeSpec.track_freq` has also been deprecated and moved to `ModeSortSpec.track_freq`. - Added `custom_source_time` parameter to `ComponentModeler` classes (`ModalComponentModeler` and `TerminalComponentModeler`), allowing specification of custom source time dependence. +- Validation for `run_only` field in component modelers to catch duplicate or invalid matrix indices early with clear error messages. ### Changed - Improved performance of antenna metrics calculation by utilizing cached wave amplitude calculations instead of recomputing wave amplitudes for each port excitation in the `TerminalComponentModelerData`. diff --git a/tests/test_plugins/smatrix/test_component_modeler.py b/tests/test_plugins/smatrix/test_component_modeler.py index c8bac6ba6f..288910c02e 100644 --- a/tests/test_plugins/smatrix/test_component_modeler.py +++ b/tests/test_plugins/smatrix/test_component_modeler.py @@ -479,3 +479,31 @@ def test_custom_source_time(monkeypatch): ): custom_source = td.GaussianPulse(freq0=td.C_0, fwidth=1e12) modeler = make_component_modeler(custom_source_time=custom_source) + + +def test_validate_run_only_uniqueness_modal(): + """Test that run_only validator rejects duplicate entries for ModalComponentModeler.""" + modeler = make_component_modeler() + + # Get valid matrix indices (port_name, mode_index) + port0_idx = (modeler.ports[0].name, 0) + port1_idx = (modeler.ports[1].name, 0) + + # Test with duplicate entries - should raise ValidationError + with pytest.raises(pydantic.ValidationError, match="duplicate entries"): + modeler.updated_copy(run_only=(port0_idx, port0_idx, port1_idx)) + + +def test_validate_run_only_membership_modal(): + """Test that run_only validator rejects invalid indices for ModalComponentModeler.""" + modeler = make_component_modeler() + + # Test with invalid port name + with pytest.raises(pydantic.ValidationError, match="not present in"): + modeler.updated_copy(run_only=(("invalid_port", 0),)) + + # Test with invalid mode index + port0_name = modeler.ports[0].name + invalid_mode = modeler.ports[0].mode_spec.num_modes + 1 + with pytest.raises(pydantic.ValidationError, match="not present in"): + modeler.updated_copy(run_only=((port0_name, invalid_mode),)) diff --git a/tests/test_plugins/smatrix/test_terminal_component_modeler.py b/tests/test_plugins/smatrix/test_terminal_component_modeler.py index b63e06be43..b31e96ff8f 100644 --- a/tests/test_plugins/smatrix/test_terminal_component_modeler.py +++ b/tests/test_plugins/smatrix/test_terminal_component_modeler.py @@ -1804,3 +1804,49 @@ def test_custom_source_time(monkeypatch, tmp_path): for source in sim.sources: assert source.source_time.freq0 == custom_source.freq0 assert source.source_time.fwidth == custom_source.fwidth + + +def test_validate_run_only_uniqueness(): + """Test that run_only validator rejects duplicate entries for TerminalComponentModeler.""" + modeler = make_component_modeler(planar_pec=True) + + # Get valid network indices + port0_idx = modeler.network_index(modeler.ports[0]) + port1_idx = modeler.network_index(modeler.ports[1]) + + # Test with duplicate entries - should raise ValidationError + with pytest.raises(pd.ValidationError, match="duplicate entries"): + modeler.updated_copy(run_only=(port0_idx, port0_idx, port1_idx)) + + +def test_validate_run_only_membership(): + """Test that run_only validator rejects invalid indices for TerminalComponentModeler.""" + modeler = make_component_modeler(planar_pec=True) + + # Test with invalid index - should raise ValidationError + with pytest.raises(pd.ValidationError, match="not present in"): + modeler.updated_copy(run_only=("invalid_port_name",)) + + # Test with partially invalid indices + port0_idx = modeler.network_index(modeler.ports[0]) + with pytest.raises(pd.ValidationError, match="not present in"): + modeler.updated_copy(run_only=(port0_idx, "invalid_port")) + + +def test_validate_run_only_with_wave_ports(): + """Test run_only validation with WavePorts in TerminalComponentModeler.""" + z_grid = td.UniformGrid(dl=1 * 1e3) + xy_grid = td.UniformGrid(dl=0.1 * 1e3) + grid_spec = td.GridSpec(grid_x=xy_grid, grid_y=xy_grid, grid_z=z_grid) + modeler = make_coaxial_component_modeler(port_types=(WavePort, WavePort), grid_spec=grid_spec) + + port0_idx = modeler.network_index(modeler.ports[0]) + port1_idx = modeler.network_index(modeler.ports[1]) + + # Valid case + modeler_updated = modeler.updated_copy(run_only=(port0_idx,)) + assert modeler_updated.run_only == (port0_idx,) + + # Invalid case + with pytest.raises(pd.ValidationError, match="not present in"): + modeler.updated_copy(run_only=("nonexistent_wave_port",)) diff --git a/tidy3d/plugins/smatrix/component_modelers/base.py b/tidy3d/plugins/smatrix/component_modelers/base.py index 35851aadcb..2891c72dbc 100644 --- a/tidy3d/plugins/smatrix/component_modelers/base.py +++ b/tidy3d/plugins/smatrix/component_modelers/base.py @@ -133,6 +133,35 @@ def _validate_element_mappings(cls, element_mappings, values): ) return element_mappings + @pd.validator("run_only", always=True) + @skip_if_fields_missing(["ports"]) + def _validate_run_only(cls, val, values): + """Validate that run_only entries are unique and exist in matrix_indices_monitor.""" + if val is None: + return val + + # Check uniqueness + if len(val) != len(set(val)): + duplicates = [idx for idx in set(val) if val.count(idx) > 1] + raise SetupError( + f"'run_only' contains duplicate entries: {duplicates}. " + "Each index must appear only once." + ) + + # Check membership - use the helper method to get valid indices + ports = values["ports"] + + valid_indices = set(cls._construct_matrix_indices_monitor(ports)) + invalid_indices = [idx for idx in val if idx not in valid_indices] + + if invalid_indices: + raise SetupError( + f"'run_only' contains indices {invalid_indices} that are not present in " + f"'matrix_indices_monitor'. Valid indices are: {sorted(valid_indices)}" + ) + + return val + _freqs_not_empty = validate_freqs_not_empty() _freqs_lower_bound = validate_freqs_min() _freqs_unique = validate_freqs_unique() @@ -206,6 +235,25 @@ def get_port_by_name(self, port_name: str) -> Port: raise Tidy3dKeyError(f'Port "{port_name}" not found.') return ports[0] + @staticmethod + @abstractmethod + def _construct_matrix_indices_monitor(ports: tuple) -> tuple[IndexType, ...]: + """Construct matrix indices for monitoring from ports. + + This helper method is used by both the matrix_indices_monitor property + and the run_only validator to ensure consistency. + + Parameters + ---------- + ports : tuple + Tuple of port objects. + + Returns + ------- + tuple[IndexType, ...] + Tuple of matrix indices for monitoring. + """ + @property @abstractmethod def matrix_indices_monitor(self) -> tuple[IndexType, ...]: diff --git a/tidy3d/plugins/smatrix/component_modelers/modal.py b/tidy3d/plugins/smatrix/component_modelers/modal.py index 60d8f6f011..699d7e09f2 100644 --- a/tidy3d/plugins/smatrix/component_modelers/modal.py +++ b/tidy3d/plugins/smatrix/component_modelers/modal.py @@ -87,6 +87,26 @@ def sim_dict(self) -> SimulationMap: sim_dict[task_name] = sim_copy return SimulationMap(keys=tuple(sim_dict.keys()), values=tuple(sim_dict.values())) + @staticmethod + def _construct_matrix_indices_monitor(ports: tuple[Port, ...]) -> tuple[MatrixIndex, ...]: + """Construct matrix indices for monitoring from modal ports. + + Parameters + ---------- + ports : tuple[Port, ...] + Tuple of Port objects. + + Returns + ------- + tuple[MatrixIndex, ...] + Tuple of (port_name, mode_index) pairs. + """ + matrix_indices = [] + for port in ports: + for mode_index in range(port.mode_spec.num_modes): + matrix_indices.append((port.name, mode_index)) + return tuple(matrix_indices) + @cached_property def matrix_indices_monitor(self) -> tuple[MatrixIndex, ...]: """Returns a tuple of all possible matrix indices for monitoring. @@ -98,11 +118,7 @@ def matrix_indices_monitor(self) -> tuple[MatrixIndex, ...]: Tuple[MatrixIndex, ...] A tuple of all possible matrix indices for the monitoring ports. """ - matrix_indices = [] - for port in self.ports: - for mode_index in range(port.mode_spec.num_modes): - matrix_indices.append((port.name, mode_index)) - return tuple(matrix_indices) + return self._construct_matrix_indices_monitor(self.ports) @cached_property def matrix_indices_source(self) -> tuple[MatrixIndex, ...]: diff --git a/tidy3d/plugins/smatrix/component_modelers/terminal.py b/tidy3d/plugins/smatrix/component_modelers/terminal.py index 38de7ddaf2..20d1420270 100644 --- a/tidy3d/plugins/smatrix/component_modelers/terminal.py +++ b/tidy3d/plugins/smatrix/component_modelers/terminal.py @@ -256,17 +256,35 @@ def network_dict(self) -> dict[NetworkIndex, tuple[TerminalPortType, int]]: network_dict[key] = (port, mode_index) return network_dict - @cached_property - def matrix_indices_monitor(self) -> tuple[NetworkIndex, ...]: - """Tuple of all the possible matrix indices.""" + @staticmethod + def _construct_matrix_indices_monitor( + ports: tuple[TerminalPortType, ...], + ) -> tuple[NetworkIndex, ...]: + """Construct matrix indices for monitoring from terminal ports. + + Parameters + ---------- + ports : tuple[TerminalPortType, ...] + Tuple of terminal port objects (LumpedPort, CoaxialLumpedPort, or WavePort). + + Returns + ------- + tuple[NetworkIndex, ...] + Tuple of network index strings. + """ matrix_indices = [] - for port in self.ports: + for port in ports: if isinstance(port, WavePort): - matrix_indices.append(self.network_index(port, port.mode_index)) + matrix_indices.append(TerminalComponentModeler.network_index(port, port.mode_index)) else: - matrix_indices.append(self.network_index(port)) + matrix_indices.append(TerminalComponentModeler.network_index(port)) return tuple(matrix_indices) + @cached_property + def matrix_indices_monitor(self) -> tuple[NetworkIndex, ...]: + """Tuple of all the possible matrix indices.""" + return self._construct_matrix_indices_monitor(self.ports) + @cached_property def matrix_indices_source(self) -> tuple[NetworkIndex, ...]: """Tuple of all the source matrix indices, which may be less than the total number of