Skip to content

Commit 71d2d07

Browse files
feat: implement structure extrusion when waveport is defined on a boundary
1 parent 707496a commit 71d2d07

File tree

4 files changed

+300
-3
lines changed

4 files changed

+300
-3
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
5151
## [v2.10.0rc1] - 2025-09-11
5252

5353
### Added
54+
- Add automatic structure extrusion for waveports defined on boundaries, controlled by the `extrude_structures` field in `WavePort`.
55+
- The extrusion method, implemented in `TerminalComponentModeler`, ensures that mode sources, absorbers, and PEC frames are fully contained within the extruded structures; extrusion occurs only when `extrude_structures` is set to `True`.
5456
- Added rectangular and radial taper support to `RectangularAntennaArrayCalculator` for phased array amplitude weighting; refactored array factor calculation for improved clarity and performance.
5557
- Selective simulation capabilities to `TerminalComponentModeler` via `run_only` and `element_mappings` fields, allowing users to run fewer simulations and extract only needed scattering matrix elements.
5658
- Added KLayout plugin, with DRC functionality for running design rule checks in `plugins.klayout.drc`. Supports running DRC on GDS files as well as `Geometry`, `Structure`, and `Simulation` objects.

tests/test_plugins/smatrix/test_terminal_component_modeler.py

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,10 @@
3333
from tidy3d.plugins.smatrix.utils import s_to_z, validate_square_matrix
3434

3535
from ...utils import run_emulated
36-
from .terminal_component_modeler_def import make_coaxial_component_modeler, make_component_modeler
36+
from .terminal_component_modeler_def import (
37+
make_coaxial_component_modeler,
38+
make_component_modeler,
39+
)
3740

3841
mm = 1e3
3942

@@ -1425,7 +1428,6 @@ def test_wave_port_to_absorber(tmp_path):
14251428
absorber = sim.internal_absorbers[0]
14261429
assert absorber.boundary_spec == custom_boundary_spec
14271430

1428-
14291431
def test_low_freq_smoothing_spec_initialization_default_values():
14301432
"""Test that LowFrequencySmoothingSpec initializes with correct default values."""
14311433
from tidy3d.plugins.smatrix.component_modelers.terminal import ModelerLowFrequencySmoothingSpec
@@ -1536,3 +1538,55 @@ def test_low_freq_smoothing_spec_sim_dict():
15361538
modeler = modeler.updated_copy(low_freq_smoothing=None)
15371539
for sim in modeler.sim_dict.values():
15381540
assert sim.low_freq_smoothing is None
1541+
1542+
def test_wave_port_extrusion_coaxial():
1543+
"""Test extrusion of structures wave port absorber."""
1544+
1545+
# define a terminal component modeler
1546+
tcm = make_coaxial_component_modeler(
1547+
length=100000,
1548+
port_types=(WavePort, WavePort),
1549+
)
1550+
1551+
# update ports and set flag to extrude structures
1552+
ports = tcm.ports
1553+
port_1 = ports[0]
1554+
port_2 = ports[1]
1555+
port_1 = port_1.updated_copy(center=(0, 0, -50000), extrude_structures=True)
1556+
port_2 = port_2.updated_copy(center=(0, 0, 50000), extrude_structures=True)
1557+
1558+
# update component modeler
1559+
tcm = tcm.updated_copy(ports=[port_1, port_2])
1560+
1561+
# generate simulations from component modeler
1562+
sims = list(tcm.sim_dict.values())
1563+
1564+
# loop over simulations
1565+
for sim in sims:
1566+
# get injection axis that would be used to extrude structure
1567+
inj_axis = sim.sources[0].injection_axis
1568+
1569+
# get grid boundaries
1570+
bnd_coords = sim.grid.boundaries.to_list[inj_axis]
1571+
1572+
# get size of structures along injection axis directions
1573+
str_bnds = [
1574+
np.min(sim.structures[0].geometry.geometries[0].slab_bounds),
1575+
np.max(sim.structures[2].geometry.geometries[0].slab_bounds),
1576+
]
1577+
1578+
pec_bnds = []
1579+
1580+
# infer placement of PEC plates beyond internal absorber
1581+
for absorber in sim.internal_absorbers:
1582+
absorber_cntr = absorber.center[inj_axis]
1583+
right_ind = np.searchsorted(bnd_coords, absorber_cntr, side="right")
1584+
left_ind = np.searchsorted(bnd_coords, absorber_cntr, side="left") - 1
1585+
pec_bnds.append(bnd_coords[right_ind + 1])
1586+
pec_bnds.append(bnd_coords[left_ind - 1])
1587+
1588+
# get range of coordinates along injection axis for PEC plates
1589+
pec_bnds = [np.min(pec_bnds), np.max(pec_bnds)]
1590+
1591+
# ensure that structures were extruded up to PEC plates
1592+
assert all(np.isclose(str_bnd, pec_bnd) for str_bnd, pec_bnd in zip(str_bnds, pec_bnds))

tidy3d/plugins/smatrix/component_modelers/terminal.py

Lines changed: 238 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import numpy as np
88
import pydantic.v1 as pd
99

10+
from tidy3d import Box, ClipOperation, GeometryGroup, GridSpec, PolySlab, Structure
1011
from tidy3d.components.base import cached_property
1112
from tidy3d.components.boundary import BroadbandModeABCSpec
1213
from tidy3d.components.frequency_extrapolation import (
@@ -291,7 +292,9 @@ def sim_dict(self) -> SimulationMap:
291292
# Now, create simulations with wave port sources and mode solver monitors for computing port modes
292293
for network_index in self.matrix_indices_run_sim:
293294
task_name, sim_with_src = self._add_source_to_sim(network_index)
294-
sim_dict[task_name] = sim_with_src
295+
296+
# extrude structures if necessary and update simulation
297+
sim_dict[task_name] = self._extrude_port_structures(sim_with_src)
295298

296299
# Check final simulations for grid size at ports
297300
for _, sim in sim_dict.items():
@@ -514,5 +517,239 @@ def get_radiation_monitor_by_name(self, monitor_name: str) -> DirectivityMonitor
514517
return monitor
515518
raise Tidy3dKeyError(f"No radiation monitor named '{monitor_name}'.")
516519

520+
def get_antenna_metrics_data(
521+
self,
522+
port_amplitudes: Optional[dict[str, complex]] = None,
523+
monitor_name: Optional[str] = None,
524+
) -> AntennaMetricsData:
525+
"""Calculate antenna parameters using superposition of fields from multiple port excitations.
526+
527+
The method computes the radiated far fields and port excitation power wave amplitudes
528+
for a superposition of port excitations, which can be used to analyze antenna radiation
529+
characteristics.
530+
531+
Parameters
532+
----------
533+
port_amplitudes : dict[str, complex] = None
534+
Dictionary mapping port names to their desired excitation amplitudes, ``a``. For each port,
535+
:math:`\\frac{1}{2}|a|^2` represents the incident power from that port into the system.
536+
If ``None``, uses only the first port without any scaling of the raw simulation data.
537+
When ``None`` is passed as a port amplitude, the raw simulation data is used for that port.
538+
Note that in this method ``a`` represents the incident wave amplitude
539+
using the power wave definition in [2].
540+
monitor_name : str = None
541+
Name of the :class:`.DirectivityMonitor` to use for calculating far fields.
542+
If None, uses the first monitor in `radiation_monitors`.
543+
544+
Returns
545+
-------
546+
:class:`.AntennaMetricsData`
547+
Container with antenna parameters including directivity, gain, and radiation efficiency,
548+
computed from the superposition of fields from all excited ports.
549+
"""
550+
# Use the first port as default if none specified
551+
if port_amplitudes is None:
552+
port_amplitudes = {self.ports[0].name: None}
553+
554+
# Check port names, and create map from port to amplitude
555+
port_dict = {}
556+
for key in port_amplitudes.keys():
557+
port, _ = self.network_dict[key]
558+
port_dict[port] = port_amplitudes[key]
559+
# Get the radiation monitor, use first as default
560+
# if none specified
561+
if monitor_name is None:
562+
rad_mon = self.radiation_monitors[0]
563+
else:
564+
rad_mon = self.get_radiation_monitor_by_name(monitor_name)
565+
566+
# Create data arrays for holding the superposition of all port power wave amplitudes
567+
f = list(rad_mon.freqs)
568+
coords = {"f": f, "port": list(self.matrix_indices_monitor)}
569+
a_sum = PortDataArray(
570+
np.zeros((len(f), len(self.matrix_indices_monitor)), dtype=complex), coords=coords
571+
)
572+
b_sum = a_sum.copy()
573+
# Retrieve associated simulation data
574+
combined_directivity_data = None
575+
for port, amplitude in port_dict.items():
576+
if amplitude == 0.0:
577+
continue
578+
sim_data_port = self.batch_data[self._task_name(port=port)]
579+
radiation_data = sim_data_port[rad_mon.name]
580+
581+
a, b = self.compute_wave_amplitudes_at_each_port(
582+
self.port_reference_impedances, sim_data_port, s_param_def="power"
583+
)
584+
# Select a possible subset of frequencies
585+
a = a.sel(f=f)
586+
b = b.sel(f=f)
587+
a_raw = a.sel(port=self.network_index(port))
588+
589+
if amplitude is None:
590+
# No scaling performed when amplitude is None
591+
scaled_directivity_data = sim_data_port[rad_mon.name]
592+
scale_factor = 1.0
593+
else:
594+
scaled_directivity_data = self._monitor_data_at_port_amplitude(
595+
port, sim_data_port, radiation_data, amplitude
596+
)
597+
scale_factor = amplitude / a_raw
598+
a = scale_factor * a
599+
b = scale_factor * b
600+
601+
# Combine the possibly scaled directivity data and the power wave amplitudes
602+
if combined_directivity_data is None:
603+
combined_directivity_data = scaled_directivity_data
604+
else:
605+
combined_directivity_data = combined_directivity_data + scaled_directivity_data
606+
a_sum += a
607+
b_sum += b
608+
609+
# Compute and add power measures to results
610+
power_incident = np.real(0.5 * a_sum * np.conj(a_sum)).sum(dim="port")
611+
power_reflected = np.real(0.5 * b_sum * np.conj(b_sum)).sum(dim="port")
612+
return AntennaMetricsData.from_directivity_data(
613+
combined_directivity_data, power_incident, power_reflected
614+
)
615+
616+
def _extrude_port_structures(self, sim: Simulation) -> Simulation:
617+
"""
618+
Extrude structures intersecting a port plane when a wave port lies on a structure boundary.
619+
620+
This method checks wave ports with ``extrude_structures==True`` and automatically extends the boundary structures
621+
to PEC plates associated with internal absorbers in the direction opposite to the mode source.
622+
This ensures that mode sources and internal absorbers are fully contained within the extrusion.
623+
624+
Parameters
625+
----------
626+
sim : Simulation
627+
Simulation object containing mode sources, internal absorbers, and monitors,
628+
after mesh overrides and snapping points are applied.
629+
630+
Returns
631+
-------
632+
Simulation
633+
Updated simulation with extruded structures added to ``simulation.structures``.
634+
"""
635+
636+
# get coordinated of the simulation grid
637+
coords = sim.grid.boundaries.to_list
638+
639+
mode_sources = []
640+
641+
# get all mode sources from TerminalComponentModeler that correspond to ports with ``extrude_structures`` flag set to ``True``.
642+
for port in self.ports:
643+
if isinstance(port, WavePort) and port.extrude_structures:
644+
# update center here (example)
645+
inj_axis = port.injection_axis
646+
647+
port_center = list(port.center)
648+
649+
idx = np.abs(port_center[inj_axis] - coords[inj_axis]).argmin()
650+
port_center[inj_axis] = coords[inj_axis][idx]
651+
652+
port = port.updated_copy(center=tuple(port_center))
653+
654+
mode_src_pos = port.center[port.injection_axis] + self._shift_value_signed(port)
655+
656+
# then convert to source
657+
mode_sources.append(port.to_source(self._source_time, snap_center=mode_src_pos))
658+
659+
# clip indices to a valid range
660+
def _clip(i, lo, hi):
661+
return int(max(lo, min(hi, i)))
662+
663+
new_structures = []
664+
665+
# loop over individual mode sources associated with waveports
666+
for mode in mode_sources:
667+
direction = mode.direction
668+
inj_axis = mode.injection_axis
669+
span_indx = np.array(sim.grid.discretize_inds(mode))
670+
671+
target_val = mode.center[inj_axis]
672+
673+
bnd_coords = coords[inj_axis]
674+
675+
offset = mode.frame.length + sim.internal_absorbers[0].grid_shift + 1
676+
677+
# get total number of boundaries along injection direction
678+
n_axis = len(bnd_coords) - 1
679+
680+
# define indicies of cells to be used for extrusion
681+
if direction == "+":
682+
idx = np.searchsorted(bnd_coords, target_val, side="left") - 1
683+
left = _clip(idx - 1, 0, n_axis)
684+
right = _clip(idx + offset, 0, n_axis)
685+
else:
686+
idx = np.searchsorted(bnd_coords, target_val, side="right")
687+
left = _clip(idx - offset, 0, n_axis)
688+
right = _clip(idx + 1, 0, n_axis)
689+
690+
# get indices for extrusion box boundaries
691+
span_indx[inj_axis][0] = left
692+
span_indx[inj_axis][1] = right
693+
694+
# get bounding box bounds
695+
box_bounds = [[c[beg], c[end]] for c, (beg, end) in zip(coords, span_indx)]
696+
697+
# construct extrusion bounding box from bounds
698+
box = Box.from_bounds(*np.transpose(box_bounds))
699+
700+
# get bounding box faces orthogonal to extrusion direction
701+
slices = box.surfaces(box.size, box.center)
702+
slice_plane_left = slices[2 * inj_axis]
703+
slice_plane_right = slices[2 * inj_axis + 1]
704+
705+
# loop over structures and extrude those that intersect a waveport plane
706+
for structure in sim.structures:
707+
# get geometries that intersect the plane on which the waveport is defined
708+
left_geom = slice_plane_left.intersections_with(structure.geometry)
709+
right_geom = slice_plane_right.intersections_with(structure.geometry)
710+
shapely_geom = left_geom or right_geom or []
711+
712+
new_geoms = []
713+
714+
# loop over identified geometries and extrude them
715+
for polygon in shapely_geom:
716+
# construct outer shell of an extruded geometry first
717+
exterior_vertices = np.array(polygon.exterior.coords)
718+
outer_shell = PolySlab(
719+
axis=inj_axis, slab_bounds=box_bounds[inj_axis], vertices=exterior_vertices
720+
)
721+
722+
# construct innner shells that represent holes
723+
hole_polyslabs = [
724+
PolySlab(
725+
axis=inj_axis,
726+
slab_bounds=box_bounds[inj_axis],
727+
vertices=np.array(hole.coords),
728+
)
729+
for hole in polygon.interiors
730+
]
731+
732+
# construct final geometry by removing inner holes from outer shell
733+
if hole_polyslabs:
734+
holes = GeometryGroup(geometries=hole_polyslabs)
735+
extruded_slab_new = ClipOperation(
736+
operation="difference", geometry_a=outer_shell, geometry_b=holes
737+
)
738+
else:
739+
extruded_slab_new = outer_shell
740+
741+
new_geoms.append(extruded_slab_new)
742+
743+
new_geoms.append(structure.geometry)
744+
745+
new_struct = Structure(
746+
geometry=GeometryGroup(geometries=new_geoms), medium=structure.medium
747+
)
748+
new_structures.append(new_struct)
749+
750+
# return simulation with added extruded structures
751+
return sim.updated_copy(grid_spec=GridSpec.from_grid(sim.grid), structures=new_structures)
752+
517753

518754
TerminalComponentModeler.update_forward_refs()
755+

tidy3d/plugins/smatrix/ports/wave.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,10 @@ class WavePort(AbstractTerminalPort, Box):
9898
"If :class:`.ABCBoundary` or :class:`.ModeABCBoundary`, a mode absorber is placed in the port with the specified boundary conditions.",
9999
)
100100

101+
extrude_structures: bool = pd.Field(
102+
False, title="Extrusion flag", description="Extrude structures attached to wave port."
103+
)
104+
101105
def _mode_voltage_coefficients(self, mode_data: ModeData) -> FreqModeDataArray:
102106
"""Calculates scaling coefficients to convert mode amplitudes
103107
to the total port voltage.

0 commit comments

Comments
 (0)