Skip to content

Commit 1bcd02e

Browse files
feat: implement structure extrusion when waveport is defined on a boundary
1 parent 6a04e86 commit 1bcd02e

File tree

4 files changed

+301
-2
lines changed

4 files changed

+301
-2
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
88
## [Unreleased]
99

1010
### Added
11+
- Add automatic structure extrusion for waveports defined on boundaries, controlled by the `extrude_structures` field in `WavePort`.
12+
- 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`.
1113
- Added rectangular and radial taper support to `RectangularAntennaArrayCalculator` for phased array amplitude weighting; refactored array factor calculation for improved clarity and performance.
1214
- 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.
1315
- 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: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,10 @@
3333
from tidy3d.plugins.smatrix.utils import 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

@@ -1351,3 +1354,56 @@ def test_wave_port_to_absorber(tmp_path):
13511354
sim = list(modeler.sim_dict.values())[0]
13521355
absorber = sim.internal_absorbers[0]
13531356
assert absorber.boundary_spec == custom_boundary_spec
1357+
1358+
1359+
def test_wave_port_extrusion_coaxial():
1360+
"""Test extrusion of structures wave port absorber."""
1361+
1362+
# define a terminal component modeler
1363+
tcm = make_coaxial_component_modeler(
1364+
length=100000,
1365+
port_types=(WavePort, WavePort),
1366+
)
1367+
1368+
# update ports and set flag to extrude structures
1369+
ports = tcm.ports
1370+
port_1 = ports[0]
1371+
port_2 = ports[1]
1372+
port_1 = port_1.updated_copy(center=(0, 0, -50000), extrude_structures=True)
1373+
port_2 = port_2.updated_copy(center=(0, 0, 50000), extrude_structures=True)
1374+
1375+
# update component modeler
1376+
tcm = tcm.updated_copy(ports=[port_1, port_2])
1377+
1378+
# generate simulations from component modeler
1379+
sims = list(tcm.sim_dict.values())
1380+
1381+
# loop over simulations
1382+
for sim in sims:
1383+
# get injection axis that would be used to extrude structure
1384+
inj_axis = sim.sources[0].injection_axis
1385+
1386+
# get grid boundaries
1387+
bnd_coords = sim.grid.boundaries.to_list[inj_axis]
1388+
1389+
# get size of structures along injection axis directions
1390+
str_bnds = [
1391+
np.min(sim.structures[0].geometry.geometries[0].slab_bounds),
1392+
np.max(sim.structures[2].geometry.geometries[0].slab_bounds),
1393+
]
1394+
1395+
pec_bnds = []
1396+
1397+
# infer placement of PEC plates beyond internal absorber
1398+
for absorber in sim.internal_absorbers:
1399+
absorber_cntr = absorber.center[inj_axis]
1400+
right_ind = np.searchsorted(bnd_coords, absorber_cntr, side="right")
1401+
left_ind = np.searchsorted(bnd_coords, absorber_cntr, side="left") - 1
1402+
pec_bnds.append(bnd_coords[right_ind + 1])
1403+
pec_bnds.append(bnd_coords[left_ind - 1])
1404+
1405+
# get range of coordinates along injection axis for PEC plates
1406+
pec_bnds = [np.min(pec_bnds), np.max(pec_bnds)]
1407+
1408+
# ensure that structures were extruded up to PEC plates
1409+
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.geometry.utils_2d import snap_coordinate_to_grid
@@ -260,7 +261,9 @@ def sim_dict(self) -> SimulationMap:
260261
# Now, create simulations with wave port sources and mode solver monitors for computing port modes
261262
for network_index in self.matrix_indices_run_sim:
262263
task_name, sim_with_src = self._add_source_to_sim(network_index)
263-
sim_dict[task_name] = sim_with_src
264+
265+
# extrude structures if necessary and update simulation
266+
sim_dict[task_name] = self._extrude_port_structures(sim_with_src)
264267

265268
# Check final simulations for grid size at ports
266269
for _, sim in sim_dict.items():
@@ -472,5 +475,239 @@ def get_radiation_monitor_by_name(self, monitor_name: str) -> DirectivityMonitor
472475
return monitor
473476
raise Tidy3dKeyError(f"No radiation monitor named '{monitor_name}'.")
474477

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

476712
TerminalComponentModeler.update_forward_refs()
713+

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 ``ABCBoundary`` or ``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)