Skip to content

Commit 8b0d789

Browse files
committed
adding microwave plugin
voltage computation completed finished up current and voltage computations with extra options for snapping to grid added the impedance calculator tool, and slight reorganization
1 parent bdb9420 commit 8b0d789

File tree

4 files changed

+376
-0
lines changed

4 files changed

+376
-0
lines changed

tidy3d/components/validators.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,19 @@
4646
MIN_FREQUENCY = 1e5
4747

4848

49+
def assert_line():
50+
"""makes sure a field's ``size`` attribute has exactly 2 zeros"""
51+
52+
@pydantic.validator("size", allow_reuse=True, always=True)
53+
def is_line(cls, val):
54+
"""Raise validation error if not 1 dimensional."""
55+
if val.count(0.0) != 2:
56+
raise ValidationError(f"'{cls.__name__}' object must be a line, given size={val}")
57+
return val
58+
59+
return is_line
60+
61+
4962
def assert_plane():
5063
"""makes sure a field's ``size`` attribute has exactly 1 zero"""
5164

tidy3d/plugins/microwave/__init__.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
""" Imports from microwave plugin. """
2+
3+
from .path_integrals import VoltageIntegralAA, CurrentIntegralAA
4+
from .impedance_calculator import ImpedanceCalculator
5+
6+
__all__ = [
7+
"VoltageIntegralAA",
8+
"CurrentIntegralAA",
9+
"ImpedanceCalculator",
10+
]
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
"""Class for computing characteristic impedance of transmission lines."""
2+
3+
from __future__ import annotations
4+
5+
6+
import pydantic.v1 as pd
7+
import numpy as np
8+
from typing import Optional
9+
from ...components.data.monitor_data import FieldData, FieldTimeData, ModeSolverData
10+
11+
from ...components.base import Tidy3dBaseModel
12+
from ...components.validators import ValidationError
13+
14+
from .path_integrals import VoltageIntegralAA, CurrentIntegralAA
15+
16+
17+
class ImpedanceCalculator(Tidy3dBaseModel):
18+
"""Tool for computing the characteristic impedance of a transmission line."""
19+
20+
voltage_integral: Optional[VoltageIntegralAA] = pd.Field(
21+
...,
22+
title="Voltage Integral",
23+
description="Integral for computing voltage.",
24+
)
25+
26+
current_integral: Optional[CurrentIntegralAA] = pd.Field(
27+
...,
28+
title="",
29+
description="Integral for computing current.",
30+
)
31+
32+
def compute_impedance(self, em_field: FieldData | ModeSolverData | FieldTimeData):
33+
# If both voltage and current integrals have been defined then impedance is computed directly
34+
if self.voltage_integral:
35+
voltage = self.voltage_integral.compute_voltage(em_field.field_components)
36+
if self.current_integral:
37+
current = self.current_integral.compute_current(em_field.field_components)
38+
39+
# If only one of the integrals has been provided then fall back to using total power (flux)
40+
# with Ohm's law. The input field should cover an area large enough to render the flux computation accurate.
41+
if not self.voltage_integral:
42+
flux = em_field.flux
43+
if isinstance(em_field, FieldTimeData):
44+
voltage = flux / current
45+
else:
46+
voltage = 2 * flux / np.conj(current)
47+
if not self.current_integral:
48+
flux = em_field.flux
49+
if isinstance(em_field, FieldTimeData):
50+
current = flux / voltage
51+
else:
52+
current = np.conj(2 * flux / voltage)
53+
54+
return voltage / current
55+
56+
@pd.validator("voltage_integral", always=True)
57+
def check_voltage_or_current(cls, val, values):
58+
"""Ensure that 'voltage_integral' and/or 'current_integral' were provided."""
59+
if not values.get("current_integral") and not val:
60+
raise ValidationError(
61+
"Atleast one of 'voltage_integral' or 'current_integral' must be provided."
62+
)
63+
return val
Lines changed: 290 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,290 @@
1+
"""Helper classes for performing path integrals with fields on the Yee grid"""
2+
3+
from __future__ import annotations
4+
5+
import pydantic.v1 as pd
6+
import numpy as np
7+
from typing import Tuple
8+
from ...components.data.dataset import FieldDataset, FieldTimeDataset, ModeSolverDataset
9+
from ...components.data.data_array import (
10+
ScalarFieldDataArray,
11+
ScalarModeFieldDataArray,
12+
ScalarFieldTimeDataArray,
13+
)
14+
from ...components.base import cached_property
15+
from ...components.types import Axis, Direction
16+
from ...components.geometry.base import Box
17+
from ...components.validators import assert_line, assert_plane
18+
from ...exceptions import DataError
19+
20+
21+
class AxisAlignedPathIntegral(Box):
22+
"""Class for defining the simplest type of path integral which is aligned with Cartesian axes."""
23+
24+
_line_validator = assert_line()
25+
26+
extrapolate_to_endpoints: bool = pd.Field(
27+
False,
28+
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.",
31+
)
32+
33+
snap_path_to_grid: bool = pd.Field(
34+
False,
35+
title="Snap path to grid",
36+
description="It might be desireable to integrate exactly along the Yee grid associated with "
37+
"a field. If enabled, the integration path will be snapped to the grid.",
38+
)
39+
40+
def compute_integral(self, scalar_field):
41+
if not scalar_field.does_cover(self.bounds):
42+
raise DataError("scalar field does not cover the integration domain")
43+
coord = "xyz"[self.integration_axis]
44+
45+
scalar_field = self._get_field_along_path(scalar_field)
46+
# Get the boundaries
47+
min_bound = self.bounds[0][self.integration_axis]
48+
max_bound = self.bounds[1][self.integration_axis]
49+
50+
if self.extrapolate_to_endpoints:
51+
# Remove field outside the boundaries
52+
scalar_field = scalar_field.sel({coord: slice(min_bound, max_bound)})
53+
# Ignore values on the boundary (sel is inclusive)
54+
scalar_field = scalar_field.drop_sel({coord: (min_bound, max_bound)}, errors="ignore")
55+
coordinates = scalar_field.coords[coord].values
56+
else:
57+
coordinates = scalar_field.coords[coord].sel({coord: slice(min_bound, max_bound)})
58+
59+
# Integration is along the original coordinates plus ensure that
60+
# endpoints corresponding to the precise bounds of the port are included
61+
coords_interp = np.array([min_bound])
62+
coords_interp = np.concatenate((coords_interp, coordinates))
63+
coords_interp = np.concatenate((coords_interp, [max_bound]))
64+
coords_interp = {coord: coords_interp}
65+
# Use extrapolation for the 2 additional endpoints
66+
scalar_field = scalar_field.interp(
67+
coords_interp, method="linear", kwargs={"fill_value": "extrapolate"}
68+
)
69+
return scalar_field.integrate(coord=coord)
70+
71+
def _get_field_along_path(
72+
self,
73+
scalar_field: ScalarFieldDataArray | ScalarModeFieldDataArray | ScalarFieldTimeDataArray,
74+
) -> ScalarFieldDataArray | ScalarModeFieldDataArray | ScalarFieldTimeDataArray:
75+
axis1 = self.remaining_axes[0]
76+
axis2 = self.remaining_axes[1]
77+
coord1 = "xyz"[axis1]
78+
coord2 = "xyz"[axis2]
79+
80+
if self.snap_path_to_grid:
81+
# Coordinates that are not integrated
82+
remaining_coords = {
83+
coord1: self.center[axis1],
84+
coord2: self.center[axis2],
85+
}
86+
# Select field nearest to center of integration line
87+
scalar_field = scalar_field.sel(
88+
remaining_coords,
89+
method="nearest",
90+
drop=False,
91+
)
92+
else:
93+
# Try to interpolate unless there is only a single coordinate
94+
coord1dict = {coord1: self.center[axis1]}
95+
if scalar_field.sizes[coord1] == 1:
96+
scalar_field = scalar_field.sel(coord1dict, method="nearest")
97+
else:
98+
scalar_field = scalar_field.interp(
99+
coord1dict, method="linear", kwargs={"bounds_error": True}
100+
)
101+
coord2dict = {coord2: self.center[axis2]}
102+
if scalar_field.sizes[coord2] == 1:
103+
scalar_field = scalar_field.sel(coord2dict, method="nearest")
104+
else:
105+
scalar_field = scalar_field.interp(
106+
coord2dict, method="linear", kwargs={"bounds_error": True}
107+
)
108+
109+
return scalar_field
110+
111+
@cached_property
112+
def integration_axis(self) -> Axis:
113+
"""Axis for performing integration"""
114+
val = next((index for index, value in enumerate(self.size) if value != 0), None)
115+
return val
116+
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+
124+
125+
class VoltageIntegralAA(AxisAlignedPathIntegral):
126+
"""Class for computing the voltage between two points defined by an axis-aligned line."""
127+
128+
sign: Direction = pd.Field(
129+
"+",
130+
title="Direction of path integral.",
131+
description="Positive indicates V=Vb-Va where position b has a larger coordinate along the axis of integration.",
132+
)
133+
134+
def compute_voltage(self, em_field: FieldDataset | ModeSolverDataset | FieldTimeDataset):
135+
"""Compute voltage along path defined by a line."""
136+
e_component = "xyz"[self.integration_axis]
137+
field_name = f"E{e_component}"
138+
# Validate that the field is present
139+
if field_name not in em_field:
140+
raise DataError(f"field_name '{field_name}' not found")
141+
e_field = em_field[f"E{e_component}"]
142+
integral = self.compute_integral(e_field)
143+
144+
voltage = -integral
145+
if self.sign == "-":
146+
voltage *= -1
147+
# Return data array of voltage while keeping coordinates of frequency|time|mode index
148+
return voltage
149+
150+
151+
class CurrentIntegralAA(Box):
152+
"""Class for computing conduction current via Ampere's Circuital Law on an axis-aligned loop."""
153+
154+
_plane_validator = assert_plane()
155+
156+
sign: Direction = pd.Field(
157+
"+",
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.",
160+
)
161+
162+
snap_contour_to_grid: bool = pd.Field(
163+
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.",
167+
)
168+
169+
def compute_current(self, em_field: FieldDataset | ModeSolverDataset | FieldTimeDataset):
170+
"""Compute current flowing in loop defined by the outer edge of a rectangle."""
171+
172+
ax1 = self.remaining_axes[0]
173+
ax2 = self.remaining_axes[1]
174+
h_component = "xyz"[ax1]
175+
v_component = "xyz"[ax2]
176+
h_field_name = f"H{h_component}"
177+
v_field_name = f"H{v_component}"
178+
# Validate that fields are present
179+
if h_field_name not in em_field:
180+
raise DataError(f"field_name '{h_field_name}' not found")
181+
if v_field_name not in em_field:
182+
raise DataError(f"field_name '{v_field_name}' not found")
183+
h_horizontal = em_field[f"H{h_component}"]
184+
h_vertical = em_field[f"H{v_component}"]
185+
186+
# Decompose contour into path integrals
187+
(bottom, right, top, left) = self._to_path_integrals(h_horizontal, h_vertical)
188+
189+
current = 0
190+
# Compute and add contributions from each part of the contour
191+
current += bottom.compute_integral(h_horizontal)
192+
current += right.compute_integral(h_vertical)
193+
current -= top.compute_integral(h_horizontal)
194+
current -= left.compute_integral(h_vertical)
195+
196+
if self.sign == "-":
197+
current *= -1
198+
# Return data array of current while keeping coordinates of frequency|time|mode index
199+
current = current.drop((h_component, v_component))
200+
return current
201+
202+
@cached_property
203+
def normal_axis(self) -> Axis:
204+
"""Axis normal to loop"""
205+
val = next((index for index, value in enumerate(self.size) if value == 0), None)
206+
return val
207+
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+
218+
def _to_path_integrals(self, h_horizontal, h_vertical) -> Tuple[AxisAlignedPathIntegral, ...]:
219+
ax1 = self.remaining_axes[0]
220+
ax2 = self.remaining_axes[1]
221+
222+
if self.snap_contour_to_grid:
223+
coord1 = "xyz"[ax1]
224+
coord2 = "xyz"[ax2]
225+
# Locations where horizontal paths will be snapped
226+
v_bounds = [
227+
self.center[ax2] - self.size[ax2] / 2,
228+
self.center[ax2] + self.size[ax2] / 2,
229+
]
230+
h_snaps = h_horizontal.sel({coord2: v_bounds}, method="nearest").coords[coord2].values
231+
# Locations where vertical paths will be snapped
232+
h_bounds = [
233+
self.center[ax1] - self.size[ax1] / 2,
234+
self.center[ax1] + self.size[ax1] / 2,
235+
]
236+
v_snaps = h_vertical.sel({coord1: h_bounds}, method="nearest").coords[coord1].values
237+
238+
bottom_bound = h_snaps[0]
239+
top_bound = h_snaps[1]
240+
left_bound = v_snaps[0]
241+
right_bound = v_snaps[1]
242+
else:
243+
bottom_bound = self.bounds[0][ax2]
244+
top_bound = self.bounds[1][ax2]
245+
left_bound = self.bounds[0][ax1]
246+
right_bound = self.bounds[1][ax1]
247+
248+
# Horizontal paths
249+
path_size = list(self.size)
250+
path_size[ax1] = right_bound - left_bound
251+
path_size[ax2] = 0
252+
path_center = list(self.center)
253+
path_center[ax2] = bottom_bound
254+
255+
bottom = AxisAlignedPathIntegral(
256+
center=path_center,
257+
size=path_size,
258+
extrapolate_to_endpoints=False,
259+
snap_path_to_grid=self.snap_contour_to_grid,
260+
)
261+
path_center[ax2] = top_bound
262+
top = AxisAlignedPathIntegral(
263+
center=path_center,
264+
size=path_size,
265+
extrapolate_to_endpoints=False,
266+
snap_path_to_grid=self.snap_contour_to_grid,
267+
)
268+
269+
# Vertical paths
270+
path_size = list(self.size)
271+
path_size[ax1] = 0
272+
path_size[ax2] = top_bound - bottom_bound
273+
path_center = list(self.center)
274+
275+
path_center[ax1] = left_bound
276+
left = AxisAlignedPathIntegral(
277+
center=path_center,
278+
size=path_size,
279+
extrapolate_to_endpoints=False,
280+
snap_path_to_grid=self.snap_contour_to_grid,
281+
)
282+
path_center[ax1] = right_bound
283+
right = AxisAlignedPathIntegral(
284+
center=path_center,
285+
size=path_size,
286+
extrapolate_to_endpoints=False,
287+
snap_path_to_grid=self.snap_contour_to_grid,
288+
)
289+
290+
return (bottom, right, top, left)

0 commit comments

Comments
 (0)