Skip to content

Commit 8fcbe0b

Browse files
feat: implement structure extrusion when waveport is defined on a boundary
1 parent a161adc commit 8fcbe0b

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
@@ -231,7 +232,9 @@ def sim_dict(self) -> SimulationMap:
231232
# Now, create simulations with wave port sources and mode solver monitors for computing port modes
232233
for network_index in self.matrix_indices_run_sim:
233234
task_name, sim_with_src = self._add_source_to_sim(network_index)
234-
sim_dict[task_name] = sim_with_src
235+
236+
# extrude structures if necessary and update simulation
237+
sim_dict[task_name] = self._extrude_port_structures(sim_with_src)
235238

236239
# Check final simulations for grid size at ports
237240
for _, sim in sim_dict.items():
@@ -443,5 +446,239 @@ def get_radiation_monitor_by_name(self, monitor_name: str) -> DirectivityMonitor
443446
return monitor
444447
raise Tidy3dKeyError(f"No radiation monitor named '{monitor_name}'.")
445448

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

447683
TerminalComponentModeler.update_forward_refs()
684+

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)