Skip to content

Commit ea052ed

Browse files
committed
reused path integrals in smatrix plugin
1 parent 8b0d789 commit ea052ed

File tree

2 files changed

+107
-131
lines changed

2 files changed

+107
-131
lines changed

tidy3d/plugins/microwave/path_integrals.py

Lines changed: 57 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -6,28 +6,44 @@
66
import numpy as np
77
from typing import Tuple
88
from ...components.data.dataset import FieldDataset, FieldTimeDataset, ModeSolverDataset
9-
from ...components.data.data_array import (
10-
ScalarFieldDataArray,
11-
ScalarModeFieldDataArray,
12-
ScalarFieldTimeDataArray,
13-
)
9+
from ...components.data.data_array import AbstractSpatialDataArray
1410
from ...components.base import cached_property
1511
from ...components.types import Axis, Direction
1612
from ...components.geometry.base import Box
1713
from ...components.validators import assert_line, assert_plane
1814
from ...exceptions import DataError
1915

2016

21-
class AxisAlignedPathIntegral(Box):
17+
class AbstractAxesRH:
18+
"""Represents an axis-aligned right-handed coordinate system with one axis preferred."""
19+
20+
@cached_property
21+
def main_axis(self) -> Axis:
22+
"""Subclasses should implement this method."""
23+
raise NotImplementedError()
24+
25+
@cached_property
26+
def remaining_axes(self) -> Tuple[Axis, Axis]:
27+
"""Axes in plane, ordered to maintain a right-handed coordinate system"""
28+
axes = [0, 1, 2]
29+
axes.pop(self.main_axis)
30+
if self.main_axis == 1:
31+
return (axes[1], axes[0])
32+
else:
33+
return (axes[0], axes[1])
34+
35+
36+
class AxisAlignedPathIntegral(AbstractAxesRH, Box):
2237
"""Class for defining the simplest type of path integral which is aligned with Cartesian axes."""
2338

2439
_line_validator = assert_line()
2540

2641
extrapolate_to_endpoints: bool = pd.Field(
2742
False,
2843
title="Extrapolate to endpoints",
29-
description="If the endpoints of the path integral terminate at or near an interface, the field is likely discontinuous. "
30-
"This option ignores fields outside the bounds of the integral.",
44+
description="If the endpoints of the path integral terminate at or near a material interface, "
45+
"the field is likely discontinuous. This option ignores fields outside and on the bounds "
46+
"of the integral. Should be turned on when computing voltage between two conductors. ",
3147
)
3248

3349
snap_path_to_grid: bool = pd.Field(
@@ -37,15 +53,16 @@ class AxisAlignedPathIntegral(Box):
3753
"a field. If enabled, the integration path will be snapped to the grid.",
3854
)
3955

40-
def compute_integral(self, scalar_field):
56+
def compute_integral(self, scalar_field: AbstractSpatialDataArray):
57+
"""Computes the defined integral given the input `scalar_field`."""
4158
if not scalar_field.does_cover(self.bounds):
4259
raise DataError("scalar field does not cover the integration domain")
43-
coord = "xyz"[self.integration_axis]
60+
coord = "xyz"[self.main_axis]
4461

4562
scalar_field = self._get_field_along_path(scalar_field)
4663
# Get the boundaries
47-
min_bound = self.bounds[0][self.integration_axis]
48-
max_bound = self.bounds[1][self.integration_axis]
64+
min_bound = self.bounds[0][self.main_axis]
65+
max_bound = self.bounds[1][self.main_axis]
4966

5067
if self.extrapolate_to_endpoints:
5168
# Remove field outside the boundaries
@@ -62,16 +79,20 @@ def compute_integral(self, scalar_field):
6279
coords_interp = np.concatenate((coords_interp, coordinates))
6380
coords_interp = np.concatenate((coords_interp, [max_bound]))
6481
coords_interp = {coord: coords_interp}
65-
# Use extrapolation for the 2 additional endpoints
82+
83+
# Use extrapolation for the 2 additional endpoints, unless there is only a single sample point
84+
method = "linear"
85+
if len(coordinates) == 1 and self.extrapolate_to_endpoints:
86+
method = "nearest"
6687
scalar_field = scalar_field.interp(
67-
coords_interp, method="linear", kwargs={"fill_value": "extrapolate"}
88+
coords_interp, method=method, kwargs={"fill_value": "extrapolate"}
6889
)
6990
return scalar_field.integrate(coord=coord)
7091

7192
def _get_field_along_path(
72-
self,
73-
scalar_field: ScalarFieldDataArray | ScalarModeFieldDataArray | ScalarFieldTimeDataArray,
74-
) -> ScalarFieldDataArray | ScalarModeFieldDataArray | ScalarFieldTimeDataArray:
93+
self, scalar_field: AbstractSpatialDataArray
94+
) -> AbstractSpatialDataArray:
95+
"""Returns a selection of the input `scalar_field` ready for integration."""
7596
axis1 = self.remaining_axes[0]
7697
axis2 = self.remaining_axes[1]
7798
coord1 = "xyz"[axis1]
@@ -109,18 +130,11 @@ def _get_field_along_path(
109130
return scalar_field
110131

111132
@cached_property
112-
def integration_axis(self) -> Axis:
113-
"""Axis for performing integration"""
133+
def main_axis(self) -> Axis:
134+
"""Axis for performing integration."""
114135
val = next((index for index, value in enumerate(self.size) if value != 0), None)
115136
return val
116137

117-
@cached_property
118-
def remaining_axes(self) -> Tuple[Axis, Axis]:
119-
"""Axes perpendicular to the voltage axis"""
120-
axes = [0, 1, 2]
121-
axes.pop(self.integration_axis)
122-
return (axes[0], axes[1])
123-
124138

125139
class VoltageIntegralAA(AxisAlignedPathIntegral):
126140
"""Class for computing the voltage between two points defined by an axis-aligned line."""
@@ -133,7 +147,7 @@ class VoltageIntegralAA(AxisAlignedPathIntegral):
133147

134148
def compute_voltage(self, em_field: FieldDataset | ModeSolverDataset | FieldTimeDataset):
135149
"""Compute voltage along path defined by a line."""
136-
e_component = "xyz"[self.integration_axis]
150+
e_component = "xyz"[self.main_axis]
137151
field_name = f"E{e_component}"
138152
# Validate that the field is present
139153
if field_name not in em_field:
@@ -148,22 +162,27 @@ def compute_voltage(self, em_field: FieldDataset | ModeSolverDataset | FieldTime
148162
return voltage
149163

150164

151-
class CurrentIntegralAA(Box):
165+
class CurrentIntegralAA(AbstractAxesRH, Box):
152166
"""Class for computing conduction current via Ampere's Circuital Law on an axis-aligned loop."""
153167

154168
_plane_validator = assert_plane()
155169

156170
sign: Direction = pd.Field(
157171
"+",
158-
title="Direction of contour integral.",
159-
description="Positive indicates V=Vb-Va where position b has a larger coordinate along the axis of integration.",
172+
title="Direction of contour integral",
173+
description="Positive indicates current flowing in the positive normal axis direction.",
174+
)
175+
176+
extrapolate_to_endpoints: bool = pd.Field(
177+
False,
178+
title="Extrapolate to endpoints",
179+
description="This parameter is passed to `AxisAlignedPathIntegral` objects when computing the contour integral.",
160180
)
161181

162182
snap_contour_to_grid: bool = pd.Field(
163183
False,
164-
title="",
165-
description="It might be desireable to integrate exactly along the Yee grid associated with "
166-
"the fields. If enabled, the integration path will be snapped to the grid.",
184+
title="Snap contour to grid",
185+
description="This parameter is passed to `AxisAlignedPathIntegral` objects when computing the contour integral.",
167186
)
168187

169188
def compute_current(self, em_field: FieldDataset | ModeSolverDataset | FieldTimeDataset):
@@ -200,21 +219,11 @@ def compute_current(self, em_field: FieldDataset | ModeSolverDataset | FieldTime
200219
return current
201220

202221
@cached_property
203-
def normal_axis(self) -> Axis:
222+
def main_axis(self) -> Axis:
204223
"""Axis normal to loop"""
205224
val = next((index for index, value in enumerate(self.size) if value == 0), None)
206225
return val
207226

208-
@cached_property
209-
def remaining_axes(self) -> Tuple[Axis, Axis]:
210-
"""Axes in integration plane, ordered to maintain a right-handed coordinate system"""
211-
axes = [0, 1, 2]
212-
axes.pop(self.normal_axis)
213-
if self.normal_axis == 1:
214-
return (axes[1], axes[0])
215-
else:
216-
return (axes[0], axes[1])
217-
218227
def _to_path_integrals(self, h_horizontal, h_vertical) -> Tuple[AxisAlignedPathIntegral, ...]:
219228
ax1 = self.remaining_axes[0]
220229
ax2 = self.remaining_axes[1]
@@ -255,14 +264,14 @@ def _to_path_integrals(self, h_horizontal, h_vertical) -> Tuple[AxisAlignedPathI
255264
bottom = AxisAlignedPathIntegral(
256265
center=path_center,
257266
size=path_size,
258-
extrapolate_to_endpoints=False,
267+
extrapolate_to_endpoints=self.extrapolate_to_endpoints,
259268
snap_path_to_grid=self.snap_contour_to_grid,
260269
)
261270
path_center[ax2] = top_bound
262271
top = AxisAlignedPathIntegral(
263272
center=path_center,
264273
size=path_size,
265-
extrapolate_to_endpoints=False,
274+
extrapolate_to_endpoints=self.extrapolate_to_endpoints,
266275
snap_path_to_grid=self.snap_contour_to_grid,
267276
)
268277

@@ -276,14 +285,14 @@ def _to_path_integrals(self, h_horizontal, h_vertical) -> Tuple[AxisAlignedPathI
276285
left = AxisAlignedPathIntegral(
277286
center=path_center,
278287
size=path_size,
279-
extrapolate_to_endpoints=False,
288+
extrapolate_to_endpoints=self.extrapolate_to_endpoints,
280289
snap_path_to_grid=self.snap_contour_to_grid,
281290
)
282291
path_center[ax1] = right_bound
283292
right = AxisAlignedPathIntegral(
284293
center=path_center,
285294
size=path_size,
286-
extrapolate_to_endpoints=False,
295+
extrapolate_to_endpoints=self.extrapolate_to_endpoints,
287296
snap_path_to_grid=self.snap_contour_to_grid,
288297
)
289298

tidy3d/plugins/smatrix/component_modelers/terminal.py

Lines changed: 50 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323
from .base import AbstractComponentModeler, FWIDTH_FRAC
2424
from ..ports.lumped import LumpedPortDataArray, LumpedPort
2525

26+
from ...microwave import VoltageIntegralAA, CurrentIntegralAA
27+
2628

2729
class TerminalComponentModeler(AbstractComponentModeler):
2830
"""Tool for modeling two-terminal multiport devices and computing port parameters
@@ -206,28 +208,17 @@ def port_voltage(port: LumpedPort, sim_data: SimulationData) -> xr.DataArray:
206208
"""Helper to compute voltage across the port."""
207209
e_component = "xyz"[port.voltage_axis]
208210
field_data = sim_data[f"{port.name}_E{e_component}"]
209-
e_field = field_data.field_components[f"E{e_component}"]
210-
# Get the boundaries of the port along the voltage axis
211-
min_port_bound = port.bounds[0][port.voltage_axis]
212-
max_port_bound = port.bounds[1][port.voltage_axis]
213-
# Remove E field outside the port region
214-
e_field = e_field.sel({e_component: slice(min_port_bound, max_port_bound)})
215-
# Ignore values on the port boundary, which are likely considered within the conductor
216-
e_field = e_field.drop_sel(
217-
{e_component: (min_port_bound, max_port_bound)}, errors="ignore"
218-
)
219-
e_coords = [e_field.x, e_field.y, e_field.z]
220-
# Integration is along the original coordinates plus two additional
221-
# endpoints corresponding to the precise bounds of the port
222-
e_coords_interp = np.array([min_port_bound])
223-
e_coords_interp = np.concatenate((e_coords_interp, e_coords[port.voltage_axis].values))
224-
e_coords_interp = np.concatenate((e_coords_interp, [max_port_bound]))
225-
e_coords_interp = {e_component: e_coords_interp}
226-
# Use extrapolation for the 2 additional endpoints
227-
e_field = e_field.interp(
228-
**e_coords_interp, method="linear", kwargs={"fill_value": "extrapolate"}
211+
212+
size = list(port.size)
213+
size[port.current_axis] = 0
214+
voltage_integral = VoltageIntegralAA(
215+
center=port.center,
216+
size=size,
217+
extrapolate_to_endpoints=True,
218+
snap_path_to_grid=True,
219+
sign="+",
229220
)
230-
voltage = -e_field.integrate(coord=e_component).squeeze(drop=True)
221+
voltage = voltage_integral.compute_voltage(field_data.field_components)
231222
# Return data array of voltage with coordinates of frequency
232223
return voltage
233224

@@ -246,83 +237,59 @@ def port_current(port: LumpedPort, sim_data: SimulationData) -> xr.DataArray:
246237

247238
# Get h field tangent to resistive sheet
248239
h_component = "xyz"[port.current_axis]
249-
orth_component = "xyz"[port.injection_axis]
250-
field_data = sim_data[f"{port.name}_H{h_component}"]
251-
h_field = field_data.field_components[f"H{h_component}"]
252-
h_coords = [h_field.x, h_field.y, h_field.z]
240+
inject_component = "xyz"[port.injection_axis]
241+
field_data = sim_data[f"{port.name}_H{h_component}"].field_components
242+
h_field = field_data[f"H{h_component}"]
243+
# Coordinates as numpy array for h_field along injection axis
244+
h_coords_along_injection = h_field.coords[inject_component].values
253245
# h_cap represents the very short section (single cell) of the H contour that
254246
# is in the injection_axis direction. It is needed to fully enclose the sheet.
255-
h_cap_field = field_data.field_components[f"H{orth_component}"]
256-
h_cap_coords = [h_cap_field.x, h_cap_field.y, h_cap_field.z]
247+
h_cap_field = field_data[f"H{inject_component}"]
248+
# Coordinates of h_cap field as numpy arrays
249+
h_cap_coords_along_current = h_cap_field.coords[h_component].values
250+
h_cap_coords_along_injection = h_cap_field.coords[inject_component].values
257251

258252
# Use the coordinates of h_cap since it lies on the same grid that the
259253
# lumped resistor is snapped to
260254
orth_index = np.argmin(
261-
np.abs(h_cap_coords[port.injection_axis].values - port.center[port.injection_axis])
255+
np.abs(h_cap_coords_along_injection - port.center[port.injection_axis])
262256
)
257+
inject_center = h_cap_coords_along_injection[orth_index]
263258
# Some sanity checks, tangent H field coordinates should be directly above
264259
# and below the coordinates of the resistive sheet
265260
assert orth_index > 0
266-
assert (
267-
h_cap_coords[port.injection_axis].values[orth_index]
268-
< h_coords[port.injection_axis].values[orth_index]
269-
)
270-
assert (
271-
h_coords[port.injection_axis].values[orth_index - 1]
272-
< h_cap_coords[port.injection_axis].values[orth_index]
273-
)
274-
275-
# Extract field just below and just above sheet
276-
h1_field = h_field.isel({orth_component: orth_index - 1})
277-
h2_field = h_field.isel({orth_component: orth_index})
278-
h_field = h1_field - h2_field
261+
assert inject_center < h_coords_along_injection[orth_index]
262+
assert h_coords_along_injection[orth_index - 1] < inject_center
263+
# Distance between the h1_field and h2_field, a single cell size
264+
dcap = h_coords_along_injection[orth_index] - h_coords_along_injection[orth_index - 1]
279265

266+
# Next find the size in the current_axis direction
280267
# Find exact bounds of port taking into consideration the Yee grid
281-
np_coords = h_coords[port.current_axis].values
268+
# Select bounds carefully and allow for h_cap very close to the port bounds
282269
port_min = port.bounds[0][port.current_axis]
283270
port_max = port.bounds[1][port.current_axis]
284-
np_coords = select_within_bounds(np_coords, port_min, port_max)
285-
coord_low = np_coords[0]
286-
coord_high = np_coords[-1]
287-
# Extract cap field which is coincident with sheet
288-
h_cap = h_cap_field.isel({orth_component: orth_index})
289-
290-
# Need to make sure to use the nearest coordinate that is
291-
# at least greater than the port bounds
292-
hcap_minus = h_cap.sel({h_component: slice(-np.inf, coord_low)})
293-
hcap_plus = h_cap.sel({h_component: slice(coord_high, np.inf)})
294-
hcap_minus = hcap_minus.isel({h_component: -1})
295-
hcap_plus = hcap_plus.isel({h_component: 0})
296-
# Length of integration along the h_cap contour is a single cell width
297-
dcap = (
298-
h_coords[port.injection_axis].values[orth_index]
299-
- h_coords[port.injection_axis].values[orth_index - 1]
300-
)
301-
302-
h_min_bound = hcap_minus.coords[h_component].values
303-
h_max_bound = hcap_plus.coords[h_component].values
304-
h_coords_interp = {
305-
h_component: np.linspace(
306-
h_min_bound,
307-
h_max_bound,
308-
len(h_coords[port.current_axis] + 2),
309-
)
310-
}
311-
# Integration that corresponds to the tangent H field
312-
h_field = h_field.interp(**h_coords_interp)
313-
current = h_field.integrate(coord=h_component).squeeze(drop=True)
314-
315-
# Integration that corresponds with the contribution to current from cap contours
316-
hcap_current = (
317-
((hcap_plus - hcap_minus) * dcap).squeeze(drop=True).reset_coords(drop=True)
271+
h_min_bound = select_within_bounds(h_cap_coords_along_current, -np.inf, port_min)[-1]
272+
h_max_bound = select_within_bounds(h_cap_coords_along_current, port_max, np.inf)[0]
273+
274+
# Setup axis aligned contour integral, which is defined by a plane
275+
# The path integral is snapped to the grid, so center and size will
276+
# be slightly modified when compared to the original port.
277+
center = list(port.center)
278+
center[port.injection_axis] = inject_center
279+
center[port.current_axis] = (h_max_bound + h_min_bound) / 2
280+
size = [0, 0, 0]
281+
size[port.current_axis] = h_max_bound - h_min_bound
282+
size[port.injection_axis] = dcap
283+
284+
# H field is continuous at integral bounds, so extrapolation is turned off
285+
I_integral = CurrentIntegralAA(
286+
center=center,
287+
size=size,
288+
sign="+",
289+
extrapolate_to_endpoints=False,
290+
snap_contour_to_grid=True,
318291
)
319-
# Add the contribution from the hcap integral
320-
current = current + hcap_current
321-
# Make sure we compute current flowing from plus to minus voltage
322-
if port.current_axis != (port.voltage_axis + 1) % 3:
323-
current *= -1
324-
# Return data array of current with coordinates of frequency
325-
return current
292+
return I_integral.compute_current(field_data)
326293

327294
def port_ab(port: LumpedPort, sim_data: SimulationData):
328295
"""Helper to compute the port incident and reflected power waves."""

0 commit comments

Comments
 (0)