witwin-maxwell 0.3.0__py3-none-win_amd64.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- witwin/maxwell/__init__.py +164 -0
- witwin/maxwell/adapters/__init__.py +1 -0
- witwin/maxwell/adapters/gds.py +203 -0
- witwin/maxwell/adapters/tidy3d.py +1669 -0
- witwin/maxwell/adjoint_inputs.py +70 -0
- witwin/maxwell/compiler/__init__.py +11 -0
- witwin/maxwell/compiler/materials.py +1355 -0
- witwin/maxwell/compiler/monitors.py +164 -0
- witwin/maxwell/compiler/sources.py +222 -0
- witwin/maxwell/fdfd/__init__.py +3 -0
- witwin/maxwell/fdfd/adjoint/__init__.py +3 -0
- witwin/maxwell/fdfd/adjoint/bridge.py +180 -0
- witwin/maxwell/fdfd/ams.py +300 -0
- witwin/maxwell/fdfd/krylov.py +280 -0
- witwin/maxwell/fdfd/plotting.py +110 -0
- witwin/maxwell/fdfd/postprocess.py +33 -0
- witwin/maxwell/fdfd/solver.py +1057 -0
- witwin/maxwell/fdtd/__init__.py +4 -0
- witwin/maxwell/fdtd/adjoint/__init__.py +21 -0
- witwin/maxwell/fdtd/adjoint/bridge.py +746 -0
- witwin/maxwell/fdtd/adjoint/capabilities.py +29 -0
- witwin/maxwell/fdtd/adjoint/core.py +3614 -0
- witwin/maxwell/fdtd/adjoint/dispatch.py +684 -0
- witwin/maxwell/fdtd/adjoint/native.py +2561 -0
- witwin/maxwell/fdtd/adjoint/profiler.py +195 -0
- witwin/maxwell/fdtd/adjoint/reverse_common.py +207 -0
- witwin/maxwell/fdtd/adjoint/seeds.py +524 -0
- witwin/maxwell/fdtd/boundary/__init__.py +37 -0
- witwin/maxwell/fdtd/boundary/bloch.py +90 -0
- witwin/maxwell/fdtd/boundary/common.py +41 -0
- witwin/maxwell/fdtd/boundary/cpml.py +527 -0
- witwin/maxwell/fdtd/boundary/runtime.py +209 -0
- witwin/maxwell/fdtd/checkpoint.py +227 -0
- witwin/maxwell/fdtd/coords.py +32 -0
- witwin/maxwell/fdtd/cuda/__init__.py +11 -0
- witwin/maxwell/fdtd/cuda/backend.py +3532 -0
- witwin/maxwell/fdtd/cuda/build.py +292 -0
- witwin/maxwell/fdtd/cuda/extension.cpp +2053 -0
- witwin/maxwell/fdtd/cuda/kernels/adjoint.cu +5290 -0
- witwin/maxwell/fdtd/cuda/kernels/aniso.cu +1704 -0
- witwin/maxwell/fdtd/cuda/kernels/boundary.cu +424 -0
- witwin/maxwell/fdtd/cuda/kernels/common.cu +74 -0
- witwin/maxwell/fdtd/cuda/kernels/common.cuh +82 -0
- witwin/maxwell/fdtd/cuda/kernels/dispersive.cu +782 -0
- witwin/maxwell/fdtd/cuda/kernels/electric.cu +3613 -0
- witwin/maxwell/fdtd/cuda/kernels/magnetic.cu +781 -0
- witwin/maxwell/fdtd/cuda/kernels/observers.cu +347 -0
- witwin/maxwell/fdtd/cuda/kernels/projection.cu +211 -0
- witwin/maxwell/fdtd/cuda/kernels/sources.cu +1482 -0
- witwin/maxwell/fdtd/cuda/kernels/spectral.cu +297 -0
- witwin/maxwell/fdtd/cuda/launch.h +23 -0
- witwin/maxwell/fdtd/cuda/prebuilt/witwin_maxwell_fdtd_cuda.pyd +0 -0
- witwin/maxwell/fdtd/cuda/runtime/__init__.py +6 -0
- witwin/maxwell/fdtd/cuda/runtime/dispatcher.py +6 -0
- witwin/maxwell/fdtd/cuda/runtime/graph.py +33 -0
- witwin/maxwell/fdtd/cuda/tensors.h +35 -0
- witwin/maxwell/fdtd/dispersion.py +48 -0
- witwin/maxwell/fdtd/evaluation.py +290 -0
- witwin/maxwell/fdtd/excitation/__init__.py +27 -0
- witwin/maxwell/fdtd/excitation/injection.py +1102 -0
- witwin/maxwell/fdtd/excitation/modes.py +1675 -0
- witwin/maxwell/fdtd/excitation/spatial.py +469 -0
- witwin/maxwell/fdtd/excitation/temporal.py +510 -0
- witwin/maxwell/fdtd/excitation/tfsf_apply.py +264 -0
- witwin/maxwell/fdtd/excitation/tfsf_common.py +421 -0
- witwin/maxwell/fdtd/excitation/tfsf_specs.py +490 -0
- witwin/maxwell/fdtd/excitation/tfsf_state.py +778 -0
- witwin/maxwell/fdtd/io.py +31 -0
- witwin/maxwell/fdtd/material_pullback.py +371 -0
- witwin/maxwell/fdtd/meshing/__init__.py +3 -0
- witwin/maxwell/fdtd/meshing/autogrid.py +335 -0
- witwin/maxwell/fdtd/observers.py +1453 -0
- witwin/maxwell/fdtd/plotting.py +121 -0
- witwin/maxwell/fdtd/postprocess.py +205 -0
- witwin/maxwell/fdtd/runtime/__init__.py +119 -0
- witwin/maxwell/fdtd/runtime/initialization.py +236 -0
- witwin/maxwell/fdtd/runtime/materials.py +1563 -0
- witwin/maxwell/fdtd/runtime/module_cache.py +31 -0
- witwin/maxwell/fdtd/runtime/spectral.py +581 -0
- witwin/maxwell/fdtd/runtime/stepping.py +1987 -0
- witwin/maxwell/fdtd/solver.py +502 -0
- witwin/maxwell/geometry/__init__.py +5 -0
- witwin/maxwell/geometry/polyslab.py +299 -0
- witwin/maxwell/materials.py +3 -0
- witwin/maxwell/media.py +1467 -0
- witwin/maxwell/monitors.py +743 -0
- witwin/maxwell/ports.py +113 -0
- witwin/maxwell/postprocess/RCS.py +139 -0
- witwin/maxwell/postprocess/__init__.py +42 -0
- witwin/maxwell/postprocess/diffraction.py +219 -0
- witwin/maxwell/postprocess/directivity.py +152 -0
- witwin/maxwell/postprocess/emission.py +67 -0
- witwin/maxwell/postprocess/modal.py +324 -0
- witwin/maxwell/postprocess/nfft.py +266 -0
- witwin/maxwell/postprocess/scattering_parameters.py +358 -0
- witwin/maxwell/postprocess/stratton_chu.py +1193 -0
- witwin/maxwell/result.py +967 -0
- witwin/maxwell/scene.py +1338 -0
- witwin/maxwell/simulation.py +659 -0
- witwin/maxwell/sources.py +917 -0
- witwin/maxwell/visualization/__init__.py +28 -0
- witwin/maxwell/visualization/interactive.py +484 -0
- witwin/maxwell/visualization/plots.py +568 -0
- witwin_maxwell-0.3.0.dist-info/METADATA +184 -0
- witwin_maxwell-0.3.0.dist-info/RECORD +107 -0
- witwin_maxwell-0.3.0.dist-info/WHEEL +4 -0
- witwin_maxwell-0.3.0.dist-info/licenses/LICENSE +13 -0
|
@@ -0,0 +1,1669 @@
|
|
|
1
|
+
"""Convert a maxwell Scene to a Tidy3D Simulation.
|
|
2
|
+
|
|
3
|
+
Usage::
|
|
4
|
+
|
|
5
|
+
td_sim = scene.to_tidy3d(frequencies=frequencies)
|
|
6
|
+
td_result = tidy3d.web.run(td_sim)
|
|
7
|
+
|
|
8
|
+
Requires ``tidy3d`` as an optional dependency.
|
|
9
|
+
|
|
10
|
+
**Unit convention**: maxwell uses metres; Tidy3D uses micrometres (um).
|
|
11
|
+
All spatial quantities are multiplied by ``length_scale`` (default 1e6)
|
|
12
|
+
during conversion. Frequencies stay in Hz.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import math
|
|
18
|
+
from typing import TYPE_CHECKING, Sequence
|
|
19
|
+
|
|
20
|
+
import numpy as np
|
|
21
|
+
from witwin.core.material import VACUUM_PERMITTIVITY
|
|
22
|
+
from ..fdtd.excitation.spatial import resolve_injection_axis, soft_plane_wave_coordinate
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
import tidy3d
|
|
26
|
+
|
|
27
|
+
# Default length conversion factor: metres to micrometres.
|
|
28
|
+
_M_TO_UM = 1e6
|
|
29
|
+
_C0 = 299_792_458.0
|
|
30
|
+
# Tidy3D's TFSF source injects 1 W/um^2 by definition, corresponding
|
|
31
|
+
# to this incident electric-field amplitude in V/um. Maxwell's TFSF
|
|
32
|
+
# source amplitude is instead the physical incident E field in V/m.
|
|
33
|
+
_TIDY3D_TFSF_UNIT_FIELD = math.sqrt(2.0 / (_C0 * VACUUM_PERMITTIVITY))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
# Helpers
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
def _ensure_tidy3d():
|
|
41
|
+
"""Import and return tidy3d, raising a clear error if missing."""
|
|
42
|
+
try:
|
|
43
|
+
import tidy3d as td
|
|
44
|
+
return td
|
|
45
|
+
except ImportError:
|
|
46
|
+
raise ImportError(
|
|
47
|
+
"tidy3d is required for Scene.to_tidy3d(). "
|
|
48
|
+
"Install it with: pip install tidy3d"
|
|
49
|
+
) from None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _scale3(vec, s):
|
|
53
|
+
"""Scale a 3-element vector by *s*, returning a Python-float tuple."""
|
|
54
|
+
return (float(vec[0]) * s, float(vec[1]) * s, float(vec[2]) * s)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _domain_to_center_size(domain, s):
|
|
58
|
+
"""Convert Domain bounds to Tidy3D (center, size) in Tidy3D units."""
|
|
59
|
+
bounds = domain.bounds
|
|
60
|
+
center = tuple((lo + hi) / 2.0 * s for lo, hi in bounds)
|
|
61
|
+
size = tuple((hi - lo) * s for lo, hi in bounds)
|
|
62
|
+
return center, size
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _polarization_to_component(polarization: tuple[float, float, float]) -> str:
|
|
66
|
+
"""Map a unit polarization vector to 'Ex', 'Ey', or 'Ez'."""
|
|
67
|
+
px, py, pz = polarization
|
|
68
|
+
mapping = {(1, 0, 0): "Ex", (0, 1, 0): "Ey", (0, 0, 1): "Ez"}
|
|
69
|
+
key = (int(round(px)), int(round(py)), int(round(pz)))
|
|
70
|
+
if key in mapping:
|
|
71
|
+
return mapping[key]
|
|
72
|
+
raise ValueError(
|
|
73
|
+
f"Cannot map polarization {polarization} to a single Tidy3D component. "
|
|
74
|
+
"Only axis-aligned unit vectors are supported."
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _axis_name_to_index(axis: str) -> int:
|
|
79
|
+
return {"x": 0, "y": 1, "z": 2}[axis]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _mode_polarization_fraction(normal_axis: str, polarization) -> str:
|
|
83
|
+
"""Map a requested tangential field axis to Tidy3D's modal polarization fraction."""
|
|
84
|
+
tangential_axes = tuple(axis for axis in "xyz" if axis != normal_axis)
|
|
85
|
+
polarization_axis = "xyz"[int(np.argmax(np.abs(polarization)))]
|
|
86
|
+
if polarization_axis not in tangential_axes:
|
|
87
|
+
raise ValueError("Mode polarization must be tangential to the source or monitor plane.")
|
|
88
|
+
return "TE_fraction" if polarization_axis == tangential_axes[0] else "TM_fraction"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _mode_sort_spec(td, normal_axis: str, polarization):
|
|
92
|
+
"""Prefer the requested field axis using Tidy3D's current mode-sorting API."""
|
|
93
|
+
return td.ModeSortSpec(
|
|
94
|
+
filter_key=_mode_polarization_fraction(normal_axis, polarization),
|
|
95
|
+
filter_reference=0.5,
|
|
96
|
+
filter_order="over",
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _mode_candidate_count(mode_index: int) -> int:
|
|
101
|
+
"""Request both polarization families through the selected modal order."""
|
|
102
|
+
return 2 * (int(mode_index) + 1)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# ---------------------------------------------------------------------------
|
|
106
|
+
# Source-time conversion (no length scaling - purely temporal / frequency)
|
|
107
|
+
# ---------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
def _convert_source_time(source_time, td, *, amplitude_scale: float = 1.0):
|
|
110
|
+
"""Convert maxwell source_time to a Tidy3D SourceTime."""
|
|
111
|
+
from ..sources import CW, GaussianPulse, RickerWavelet
|
|
112
|
+
|
|
113
|
+
if source_time is None:
|
|
114
|
+
raise ValueError("source_time must be set for Tidy3D export.")
|
|
115
|
+
|
|
116
|
+
if isinstance(source_time, CW):
|
|
117
|
+
return td.ContinuousWave(
|
|
118
|
+
freq0=source_time.frequency,
|
|
119
|
+
fwidth=0.1 * source_time.frequency,
|
|
120
|
+
amplitude=source_time.amplitude * amplitude_scale,
|
|
121
|
+
phase=source_time.phase,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
if isinstance(source_time, GaussianPulse):
|
|
125
|
+
offset = source_time.delay / source_time.sigma_t
|
|
126
|
+
return td.GaussianPulse(
|
|
127
|
+
freq0=source_time.frequency,
|
|
128
|
+
fwidth=source_time.fwidth,
|
|
129
|
+
amplitude=source_time.amplitude * amplitude_scale,
|
|
130
|
+
# Tidy3D removes the arbitrary waveform delay during source-spectrum
|
|
131
|
+
# normalization while preserving this user-controlled phase.
|
|
132
|
+
phase=source_time.phase,
|
|
133
|
+
offset=offset,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
if isinstance(source_time, RickerWavelet):
|
|
137
|
+
return td.GaussianPulse(
|
|
138
|
+
freq0=source_time.frequency,
|
|
139
|
+
fwidth=source_time.frequency,
|
|
140
|
+
amplitude=source_time.amplitude * amplitude_scale,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
raise TypeError(f"Unsupported source_time type: {type(source_time).__name__}")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# ---------------------------------------------------------------------------
|
|
147
|
+
# Material conversion (no length scaling - dimensionless or frequency-based)
|
|
148
|
+
# ---------------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
def _conductivity_pole(sigma_e: float):
|
|
151
|
+
"""Zero-frequency PoleResidue pole encoding a static electric conductivity.
|
|
152
|
+
|
|
153
|
+
Under the e^{-i*omega*t} convention a passive conductor adds a loss term
|
|
154
|
+
+i*sigma/(omega*eps0) to the relative permittivity. In Tidy3D's PoleResidue
|
|
155
|
+
model ``eps(w) = eps_inf - sum_i [ c_i/(iw + a_i) + c_i*/(iw + a_i*) ]`` a real
|
|
156
|
+
residue ``c`` placed at the pole ``a = 0`` contributes ``+2i*Re(c)/w``, so
|
|
157
|
+
``Re(c) = sigma/(2*eps0)`` reproduces the conductivity term exactly. The residue
|
|
158
|
+
lives in the (dimensionless) permittivity domain with ``w`` in rad/s, so it is
|
|
159
|
+
independent of the export length scale. ``eps0`` is the SI vacuum permittivity,
|
|
160
|
+
matching ``Material.relative_permittivity``.
|
|
161
|
+
"""
|
|
162
|
+
return (complex(0.0, 0.0), complex(float(sigma_e) / (2.0 * VACUUM_PERMITTIVITY), 0.0))
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _dispersive_medium(material, td, nonlinear_spec=None):
|
|
166
|
+
"""Convert a dispersive Maxwell Material (ignoring sigma_e) to a Tidy3D medium.
|
|
167
|
+
|
|
168
|
+
An optional ``nonlinear_spec`` is forwarded to the constructed medium so a
|
|
169
|
+
material that is both dispersive and Kerr/TPA nonlinear exports as a single
|
|
170
|
+
dispersive+nonlinear Tidy3D medium.
|
|
171
|
+
"""
|
|
172
|
+
has_debye = bool(material.debye_poles)
|
|
173
|
+
has_drude = bool(material.drude_poles)
|
|
174
|
+
has_lorentz = bool(material.lorentz_poles)
|
|
175
|
+
extra = {} if nonlinear_spec is None else {"nonlinear_spec": nonlinear_spec}
|
|
176
|
+
|
|
177
|
+
if has_drude and not has_debye and not has_lorentz:
|
|
178
|
+
coeffs = [
|
|
179
|
+
(pole.plasma_frequency, pole.gamma)
|
|
180
|
+
for pole in material.drude_poles
|
|
181
|
+
]
|
|
182
|
+
return td.Drude(eps_inf=material.eps_r, coeffs=coeffs, **extra)
|
|
183
|
+
|
|
184
|
+
if has_lorentz and not has_debye and not has_drude:
|
|
185
|
+
coeffs = [
|
|
186
|
+
# Tidy3D writes the Lorentz denominator as
|
|
187
|
+
# f0^2 - f^2 - 2j*f*delta, while Maxwell stores gamma in
|
|
188
|
+
# f0^2 - f^2 - j*f*gamma after converting angular frequency to Hz.
|
|
189
|
+
(pole.delta_eps, pole.resonance_frequency, 0.5 * pole.gamma)
|
|
190
|
+
for pole in material.lorentz_poles
|
|
191
|
+
]
|
|
192
|
+
return td.Lorentz(eps_inf=material.eps_r, coeffs=coeffs, **extra)
|
|
193
|
+
|
|
194
|
+
if has_debye and not has_drude and not has_lorentz:
|
|
195
|
+
coeffs = [
|
|
196
|
+
# Tidy3D uses 1 - j*f*tau, whereas Maxwell's DebyePole uses
|
|
197
|
+
# 1 - j*omega*tau. Scale tau so both describe the same response.
|
|
198
|
+
(pole.delta_eps, 2.0 * math.pi * pole.tau)
|
|
199
|
+
for pole in material.debye_poles
|
|
200
|
+
]
|
|
201
|
+
return td.Debye(eps_inf=material.eps_r, coeffs=coeffs, **extra)
|
|
202
|
+
|
|
203
|
+
# Mixed pole types to PoleResidue. Let Tidy3D lower each specialized
|
|
204
|
+
# medium into its own PoleResidue representation so its sign and damping
|
|
205
|
+
# conventions remain exactly consistent with eps_model().
|
|
206
|
+
poles = []
|
|
207
|
+
for p in material.drude_poles:
|
|
208
|
+
medium = td.Drude(
|
|
209
|
+
eps_inf=1.0,
|
|
210
|
+
coeffs=((p.plasma_frequency, p.gamma),),
|
|
211
|
+
)
|
|
212
|
+
poles.extend(medium.pole_residue.poles)
|
|
213
|
+
for p in material.lorentz_poles:
|
|
214
|
+
medium = td.Lorentz(
|
|
215
|
+
eps_inf=1.0,
|
|
216
|
+
coeffs=((p.delta_eps, p.resonance_frequency, 0.5 * p.gamma),),
|
|
217
|
+
allow_gain=bool(p.allow_gain),
|
|
218
|
+
)
|
|
219
|
+
poles.extend(medium.pole_residue.poles)
|
|
220
|
+
for p in material.debye_poles:
|
|
221
|
+
medium = td.Debye(
|
|
222
|
+
eps_inf=1.0,
|
|
223
|
+
coeffs=((p.delta_eps, 2.0 * math.pi * p.tau),),
|
|
224
|
+
)
|
|
225
|
+
poles.extend(medium.pole_residue.poles)
|
|
226
|
+
|
|
227
|
+
extra = {} if nonlinear_spec is None else {"nonlinear_spec": nonlinear_spec}
|
|
228
|
+
return td.PoleResidue(eps_inf=material.eps_r, poles=tuple(poles), **extra)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _axis_isotropic_material(material, axis_index: int):
|
|
232
|
+
"""Project an axis-aligned anisotropic Material onto one principal axis.
|
|
233
|
+
|
|
234
|
+
Diagonal (``DiagonalTensor3``) anisotropy is three independent isotropic media,
|
|
235
|
+
one per lab-frame axis. Electric dispersion enters each axis isotropically
|
|
236
|
+
(``chi(omega) * I``), so the per-axis medium keeps the material's homogeneous
|
|
237
|
+
pole set and only its background permittivity/conductivity change per axis.
|
|
238
|
+
Returning a fresh isotropic ``Material`` lets ``_convert_material`` reuse the
|
|
239
|
+
full isotropic pole/conductivity conversion (and its e^{-iwt} sign and
|
|
240
|
+
length-scale conventions) for each Tidy3D ``AnisotropicMedium`` component.
|
|
241
|
+
"""
|
|
242
|
+
from ..media import DiagonalTensor3, Material
|
|
243
|
+
|
|
244
|
+
eps_tensor = material.epsilon_tensor
|
|
245
|
+
if isinstance(eps_tensor, DiagonalTensor3):
|
|
246
|
+
eps_axis = eps_tensor.as_tuple()[axis_index]
|
|
247
|
+
else:
|
|
248
|
+
eps_axis = float(material.eps_r)
|
|
249
|
+
|
|
250
|
+
sigma_tensor = material.sigma_e_tensor
|
|
251
|
+
if isinstance(sigma_tensor, DiagonalTensor3):
|
|
252
|
+
sigma_axis = sigma_tensor.as_tuple()[axis_index]
|
|
253
|
+
else:
|
|
254
|
+
sigma_axis = float(material.sigma_e)
|
|
255
|
+
|
|
256
|
+
return Material(
|
|
257
|
+
eps_r=eps_axis,
|
|
258
|
+
sigma_e=sigma_axis,
|
|
259
|
+
debye_poles=material.debye_poles,
|
|
260
|
+
drude_poles=material.drude_poles,
|
|
261
|
+
lorentz_poles=material.lorentz_poles,
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _fully_anisotropic_medium(material, td, length_scale: float):
|
|
266
|
+
"""Convert a full off-diagonal ``Tensor3x3`` permittivity to a Tidy3D medium.
|
|
267
|
+
|
|
268
|
+
Tidy3D's ``FullyAnisotropicMedium`` carries a symmetric-positive-definite 3x3
|
|
269
|
+
permittivity tensor plus a conductivity tensor that must share its principal
|
|
270
|
+
axes; the maxwell tensor is validated SPD at construction, so it maps across
|
|
271
|
+
row-for-row. The conductivity is diagonal in the lab frame (scalar ``sigma_e``
|
|
272
|
+
or a ``DiagonalTensor3``) and scaled by the metre->Tidy3D length factor exactly
|
|
273
|
+
as the isotropic path, so ``eps'' = sigma/(w*eps0)`` matches physically. When the
|
|
274
|
+
diagonal conductivity does not share axes with an off-diagonal permittivity,
|
|
275
|
+
Tidy3D's own commutation validator rejects the medium.
|
|
276
|
+
"""
|
|
277
|
+
if material.is_dispersive:
|
|
278
|
+
raise NotImplementedError(
|
|
279
|
+
"Tidy3D's FullyAnisotropicMedium is strictly non-dispersive, so a full "
|
|
280
|
+
"off-diagonal permittivity tensor combined with dispersive poles has no Tidy3D "
|
|
281
|
+
"equivalent; supply an axis-aligned DiagonalTensor3 (which exports as a per-axis "
|
|
282
|
+
"dispersive AnisotropicMedium) or drop the dispersion."
|
|
283
|
+
)
|
|
284
|
+
from ..media import DiagonalTensor3
|
|
285
|
+
|
|
286
|
+
rows = material.epsilon_tensor.rows
|
|
287
|
+
permittivity = [[float(rows[i][j]) for j in range(3)] for i in range(3)]
|
|
288
|
+
|
|
289
|
+
sigma_tensor = material.sigma_e_tensor
|
|
290
|
+
if isinstance(sigma_tensor, DiagonalTensor3):
|
|
291
|
+
sigma_diag = sigma_tensor.as_tuple()
|
|
292
|
+
else:
|
|
293
|
+
sigma = float(material.sigma_e)
|
|
294
|
+
sigma_diag = (sigma, sigma, sigma)
|
|
295
|
+
conductivity = [
|
|
296
|
+
[sigma_diag[i] / length_scale if i == j else 0.0 for j in range(3)]
|
|
297
|
+
for i in range(3)
|
|
298
|
+
]
|
|
299
|
+
return td.FullyAnisotropicMedium(permittivity=permittivity, conductivity=conductivity)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _anisotropic_medium(material, td, length_scale: float):
|
|
303
|
+
"""Convert an anisotropic Material to a Tidy3D anisotropic medium.
|
|
304
|
+
|
|
305
|
+
Axis-aligned ``DiagonalTensor3`` permittivity/conductivity exports as a
|
|
306
|
+
``td.AnisotropicMedium`` of three per-axis isotropic media (dispersion allowed);
|
|
307
|
+
a full off-diagonal ``Tensor3x3`` exports as a non-dispersive
|
|
308
|
+
``td.FullyAnisotropicMedium``. A magnetic ``mu_tensor`` has no Tidy3D counterpart.
|
|
309
|
+
"""
|
|
310
|
+
if material.mu_tensor is not None:
|
|
311
|
+
raise NotImplementedError(
|
|
312
|
+
"Tidy3D has no anisotropic magnetic medium: its AnisotropicMedium and "
|
|
313
|
+
"FullyAnisotropicMedium describe the electric permittivity/conductivity tensor "
|
|
314
|
+
"with mu_r = 1, so a mu_tensor (anisotropic permeability) has no Tidy3D equivalent."
|
|
315
|
+
)
|
|
316
|
+
if material.has_full_epsilon_tensor:
|
|
317
|
+
return _fully_anisotropic_medium(material, td, length_scale)
|
|
318
|
+
|
|
319
|
+
components = [
|
|
320
|
+
_convert_material(_axis_isotropic_material(material, axis), td, length_scale)
|
|
321
|
+
for axis in range(3)
|
|
322
|
+
]
|
|
323
|
+
return td.AnisotropicMedium(xx=components[0], yy=components[1], zz=components[2])
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _nonlinear_spec(material, td, length_scale: float):
|
|
327
|
+
"""Build a Tidy3D ``NonlinearSpec`` for a Material's nonlinear channels, or None.
|
|
328
|
+
|
|
329
|
+
maxwell and Tidy3D share the instantaneous Kerr form ``P_NL = eps0*chi3*|E|^2*E``
|
|
330
|
+
and the two-photon-absorption model, so the chi3 (Kerr) and TPA channels map
|
|
331
|
+
directly onto ``td.NonlinearSusceptibility`` and ``td.TwoPhotonAbsorption``.
|
|
332
|
+
|
|
333
|
+
Unit conventions (Tidy3D is a micrometre solver with ``E`` in [V/um]):
|
|
334
|
+
|
|
335
|
+
* ``chi3`` is [m^2/V^2] in maxwell and [um^2/V^2] in Tidy3D. The dimensionless
|
|
336
|
+
fractional-permittivity correction ``chi3*|E|^2`` must be preserved, and a
|
|
337
|
+
physical field of ``E`` [V/m] equals ``E/length_scale`` [V/um], so
|
|
338
|
+
``chi3_um = chi3_SI * length_scale**2``. (Cross-checked via Tidy3D's own
|
|
339
|
+
``n2 = 3/(4 n0^2 eps0 c0) chi3`` relation, which then yields the same physical
|
|
340
|
+
``n2`` scaled to [um^2/W].)
|
|
341
|
+
* TPA ``beta`` is [m/W] in maxwell and [um/W] in Tidy3D, so
|
|
342
|
+
``beta_um = beta_SI * length_scale``. ``n0`` (the linear index used in the
|
|
343
|
+
intensity conversion) defaults to ``sqrt(eps_r)`` in maxwell and is forwarded
|
|
344
|
+
explicitly; otherwise Tidy3D would infer it from the source frequencies.
|
|
345
|
+
|
|
346
|
+
Second-order (chi2) susceptibility has no public Tidy3D equivalent (Tidy3D's
|
|
347
|
+
public nonlinear API is the chi3/Kerr/TPA family only) and is rejected.
|
|
348
|
+
"""
|
|
349
|
+
from ..media import TwoPhotonAbsorption
|
|
350
|
+
|
|
351
|
+
if not material.is_nonlinear:
|
|
352
|
+
return None
|
|
353
|
+
|
|
354
|
+
if material.nonlinear_chi2 != 0.0:
|
|
355
|
+
raise NotImplementedError(
|
|
356
|
+
"Tidy3D has no second-order (chi2 / second-harmonic-generation) nonlinear "
|
|
357
|
+
"medium; its public nonlinear API is the chi3 / Kerr / two-photon-absorption "
|
|
358
|
+
"family only. Validate chi2 against an FDTD analytic reference instead."
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
models = []
|
|
362
|
+
|
|
363
|
+
chi3 = material.nonlinear_chi3
|
|
364
|
+
if chi3 != 0.0:
|
|
365
|
+
models.append(td.NonlinearSusceptibility(chi3=chi3 * length_scale ** 2))
|
|
366
|
+
|
|
367
|
+
tpa_specs = [s for s in material.nonlinearity if isinstance(s, TwoPhotonAbsorption)]
|
|
368
|
+
if tpa_specs:
|
|
369
|
+
base_index = float(math.sqrt(float(material.eps_r)))
|
|
370
|
+
n0_values = {
|
|
371
|
+
float(spec.n0) if spec.n0 is not None else base_index for spec in tpa_specs
|
|
372
|
+
}
|
|
373
|
+
if len(n0_values) != 1:
|
|
374
|
+
raise ValueError(
|
|
375
|
+
"Tidy3D represents two-photon absorption with a single model carrying one "
|
|
376
|
+
f"linear index n0, but the material combines TPA terms with differing n0 "
|
|
377
|
+
f"({sorted(n0_values)}); merge them or give them a common n0."
|
|
378
|
+
)
|
|
379
|
+
n0 = n0_values.pop()
|
|
380
|
+
beta = sum(float(spec.beta) for spec in tpa_specs)
|
|
381
|
+
models.append(td.TwoPhotonAbsorption(beta=beta * length_scale, n0=n0))
|
|
382
|
+
|
|
383
|
+
return td.NonlinearSpec(models=models)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _modulated_medium(material, modulation, td, length_scale: float):
|
|
387
|
+
"""Convert a time-modulated Material to a Tidy3D ``Medium`` with a ``ModulationSpec``.
|
|
388
|
+
|
|
389
|
+
maxwell's modulation makes the static permittivity harmonic,
|
|
390
|
+
|
|
391
|
+
``eps(x, t) = eps_static(x) * (1 + amplitude(x) * cos(2*pi*f*t + phase(x)))``,
|
|
392
|
+
|
|
393
|
+
so the *absolute* permittivity deviation is
|
|
394
|
+
``delta_eps(x, t) = eps_static * amplitude * cos(2*pi*f*t + phase)``.
|
|
395
|
+
|
|
396
|
+
Tidy3D adds a separable space-time modulation to the non-dispersive part
|
|
397
|
+
(eps_inf / conductivity) of a medium,
|
|
398
|
+
|
|
399
|
+
``delta_eps(r, t) = Re[amp_time(t) * amp_space(r)]``, with
|
|
400
|
+
``amp_time(t) = A_t * e^{i*phi_t - 2*pi*i*f*t}`` and
|
|
401
|
+
``amp_space(r) = A_s(r) * e^{i*phi_s(r)}``.
|
|
402
|
+
|
|
403
|
+
Putting all magnitude/phase in the space part (``A_t = 1``, ``phi_t = 0``) gives
|
|
404
|
+
``delta_eps = A_s * cos(2*pi*f*t - phi_s)``. Matching maxwell term-for-term:
|
|
405
|
+
|
|
406
|
+
* ``A_s = eps_static * amplitude`` (Tidy3D's amplitude is an *absolute* permittivity
|
|
407
|
+
deviation, not the dimensionless depth), and
|
|
408
|
+
* ``phi_s = -phase`` (the sign flips because Tidy3D's e^{-i*omega*t} time factor is
|
|
409
|
+
the conjugate of maxwell's ``+phase`` convention; cosine parity turns
|
|
410
|
+
``cos(2*pi*f*t + phi_s)`` back into ``cos(2*pi*f*t + phase)``).
|
|
411
|
+
|
|
412
|
+
maxwell caps the modulation depth at ``< 0.5``, so ``A_s < 0.5*eps_static`` and the
|
|
413
|
+
modulated permittivity stays strictly positive, satisfying Tidy3D's own
|
|
414
|
+
non-negative-permittivity modulation validator.
|
|
415
|
+
"""
|
|
416
|
+
# maxwell forbids a modulated Material from carrying anisotropy or a static
|
|
417
|
+
# electric conductivity at construction, so a modulated export is always an
|
|
418
|
+
# isotropic Medium whose only modulated quantity is eps_inf. Dispersion and the
|
|
419
|
+
# instantaneous nonlinear channels, however, are folded through the SAME per-step
|
|
420
|
+
# modulation factor as the eps_inf background in maxwell's runtime, whereas Tidy3D's
|
|
421
|
+
# ModulationSpec modulates ONLY the non-dispersive part and leaves the pole /
|
|
422
|
+
# nonlinear polarization currents unmodulated. The two models therefore disagree,
|
|
423
|
+
# so a modulated + dispersive / + nonlinear material has no equivalent Tidy3D medium.
|
|
424
|
+
if material.is_dispersive or material.is_nonlinear:
|
|
425
|
+
raise NotImplementedError(
|
|
426
|
+
"Tidy3D's ModulationSpec modulates only the non-dispersive permittivity "
|
|
427
|
+
"(eps_inf) and conductivity, but maxwell folds the same per-step modulation "
|
|
428
|
+
"factor through the dispersive polarization current and the instantaneous "
|
|
429
|
+
"Kerr/chi2/TPA coefficient, so a time-modulated Material that also carries "
|
|
430
|
+
"dispersion or a nonlinearity has no equivalent Tidy3D construct. Validate the "
|
|
431
|
+
"modulated dispersive/nonlinear cell against an FDTD analytic reference instead."
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
if not isinstance(modulation.amplitude, float) or not isinstance(modulation.phase, float):
|
|
435
|
+
raise NotImplementedError(
|
|
436
|
+
"Tidy3D represents a spatially-varying modulation profile as a SpaceModulation "
|
|
437
|
+
"carrying a SpatialDataArray on absolute simulation coordinates, but maxwell's "
|
|
438
|
+
"per-cell modulation amplitude/phase tensors are defined relative to the owning "
|
|
439
|
+
"structure's Box and are not available at material-conversion time. Export a "
|
|
440
|
+
"scalar-amplitude, scalar-phase ModulationSpec instead."
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
eps_static = float(material.eps_r)
|
|
444
|
+
space_modulation = td.SpaceModulation(
|
|
445
|
+
amplitude=eps_static * modulation.amplitude,
|
|
446
|
+
phase=-modulation.phase,
|
|
447
|
+
)
|
|
448
|
+
time_modulation = td.ContinuousWaveTimeModulation(
|
|
449
|
+
freq0=modulation.frequency,
|
|
450
|
+
amplitude=1.0,
|
|
451
|
+
phase=0.0,
|
|
452
|
+
)
|
|
453
|
+
modulation_spec = td.ModulationSpec(
|
|
454
|
+
permittivity=td.SpaceTimeModulation(
|
|
455
|
+
space_modulation=space_modulation,
|
|
456
|
+
time_modulation=time_modulation,
|
|
457
|
+
)
|
|
458
|
+
)
|
|
459
|
+
return td.Medium(permittivity=eps_static, modulation_spec=modulation_spec)
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def _sheet_surface_medium(medium, td, length_scale: float):
|
|
463
|
+
"""Build the Tidy3D surface-conductivity medium reproducing a ``Medium2D`` sheet.
|
|
464
|
+
|
|
465
|
+
A maxwell ``Medium2D`` carries the complex sheet conductivity ``sigma_s(omega)``
|
|
466
|
+
[S] (siemens; a *surface* conductance ``J_s = sigma_s * E_t``, so it is
|
|
467
|
+
unit-system independent and is NOT length-scaled). Tidy3D models a 2D sheet as
|
|
468
|
+
``td.Medium2D(ss, tt)`` whose per-tangential media report the sheet conductivity
|
|
469
|
+
through ``sigma_model(omega) = (eps_inf - eps(omega)) * i * omega * eps0``. This
|
|
470
|
+
returns the isotropic ``ss == tt`` medium whose ``sigma_model`` equals
|
|
471
|
+
``medium.sheet_conductivity`` at every frequency:
|
|
472
|
+
|
|
473
|
+
* a purely static sheet (only ``sigma_s``) is ``td.Medium(conductivity=sigma_s)``
|
|
474
|
+
because ``Medium.sigma_model`` returns the conductivity verbatim (``eps0``
|
|
475
|
+
cancels), so the sheet conductance is passed through unscaled;
|
|
476
|
+
* a single Drude sheet term ``weight/(rate - i*omega)`` (the graphene intraband
|
|
477
|
+
Kubo channel) is a ``td.Drude`` with ``plasma_frequency = sqrt(weight/eps0)``
|
|
478
|
+
and ``gamma = rate``, since ``td``'s Drude conductivity evaluates to
|
|
479
|
+
``eps0*omega_p^2/(gamma_ang - i*omega)``;
|
|
480
|
+
* any richer combination (static + Drude + Lorentz, e.g. graphene with the fitted
|
|
481
|
+
interband Lorentz sheet terms) folds into a single ``td.PoleResidue`` by summing
|
|
482
|
+
each channel's pole-residue contribution (the static conductance maps to the
|
|
483
|
+
``a = 0`` pole ``c = sigma_s/(2*eps0)``, and the Drude/Lorentz sheet terms reuse
|
|
484
|
+
Tidy3D's own ``Drude``/``Lorentz`` -> ``pole_residue`` conversion).
|
|
485
|
+
|
|
486
|
+
``eps0`` is Tidy3D's micrometre-unit vacuum permittivity ``eps0_SI / length_scale``.
|
|
487
|
+
It cancels in ``sigma_model`` for the static channel and sets the Drude/Lorentz
|
|
488
|
+
residue magnitudes for the dispersive ones, so the reported sheet conductance stays
|
|
489
|
+
in physical siemens.
|
|
490
|
+
"""
|
|
491
|
+
eps0 = VACUUM_PERMITTIVITY / length_scale
|
|
492
|
+
sigma_s = float(medium.sigma_s)
|
|
493
|
+
drude_terms = tuple(medium.sheet_pole_terms())
|
|
494
|
+
lorentz_terms = tuple(medium.sheet_lorentz_terms())
|
|
495
|
+
|
|
496
|
+
if not drude_terms and not lorentz_terms:
|
|
497
|
+
kwargs = {"permittivity": 1.0}
|
|
498
|
+
if sigma_s != 0.0:
|
|
499
|
+
kwargs["conductivity"] = sigma_s
|
|
500
|
+
return td.Medium(**kwargs)
|
|
501
|
+
|
|
502
|
+
if sigma_s == 0.0 and len(drude_terms) == 1 and not lorentz_terms:
|
|
503
|
+
weight, rate = drude_terms[0]
|
|
504
|
+
plasma_frequency = math.sqrt(weight / eps0) / (2.0 * math.pi)
|
|
505
|
+
gamma = rate / (2.0 * math.pi)
|
|
506
|
+
return td.Drude(eps_inf=1.0, coeffs=((plasma_frequency, gamma),))
|
|
507
|
+
|
|
508
|
+
poles: list = []
|
|
509
|
+
if sigma_s != 0.0:
|
|
510
|
+
poles.append((complex(0.0, 0.0), complex(sigma_s / (2.0 * eps0), 0.0)))
|
|
511
|
+
for weight, rate in drude_terms:
|
|
512
|
+
plasma_frequency = math.sqrt(weight / eps0) / (2.0 * math.pi)
|
|
513
|
+
gamma = rate / (2.0 * math.pi)
|
|
514
|
+
poles.extend(
|
|
515
|
+
td.Drude(eps_inf=1.0, coeffs=((plasma_frequency, gamma),)).pole_residue.poles
|
|
516
|
+
)
|
|
517
|
+
for strength, omega_0, gamma in lorentz_terms:
|
|
518
|
+
# td.Lorentz eps uses eps_inf + de*f0^2/(f0^2 - 2i*f*delta - f^2), so its
|
|
519
|
+
# angular damping is 2*(2*pi*delta); matching the sheet term's angular gamma
|
|
520
|
+
# gives delta = gamma / (4*pi), and eps0*de = strength gives de = strength/eps0.
|
|
521
|
+
delta_eps = strength / eps0
|
|
522
|
+
resonance = omega_0 / (2.0 * math.pi)
|
|
523
|
+
damping = gamma / (4.0 * math.pi)
|
|
524
|
+
poles.extend(
|
|
525
|
+
td.Lorentz(eps_inf=1.0, coeffs=((delta_eps, resonance, damping),)).pole_residue.poles
|
|
526
|
+
)
|
|
527
|
+
return td.PoleResidue(eps_inf=1.0, poles=tuple(poles))
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def _medium2d(material, td, length_scale: float):
|
|
531
|
+
"""Convert a maxwell ``Medium2D`` (including ``Graphene``) to a Tidy3D ``Medium2D``.
|
|
532
|
+
|
|
533
|
+
The sheet is isotropic in its tangential plane, so both Tidy3D tangential surface
|
|
534
|
+
media ``ss`` and ``tt`` are the same surface-conductivity medium reproducing
|
|
535
|
+
``material.sheet_conductivity(omega)``. ``Graphene`` exports through the same path:
|
|
536
|
+
its intraband Drude sheet term and any fitted interband Lorentz sheet terms are
|
|
537
|
+
carried by ``_sheet_surface_medium``.
|
|
538
|
+
"""
|
|
539
|
+
surface = _sheet_surface_medium(material, td, length_scale)
|
|
540
|
+
return td.Medium2D(ss=surface, tt=surface)
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def _lossy_metal_medium(material, td, length_scale: float, frequencies):
|
|
544
|
+
"""Convert a maxwell ``LossyMetalMedium`` to a Tidy3D ``LossyMetalMedium``.
|
|
545
|
+
|
|
546
|
+
Both model a good conductor through a surface-impedance (Leontovich) boundary
|
|
547
|
+
condition. The maxwell bulk ``conductivity`` [S/m] maps to Tidy3D's ``conductivity``
|
|
548
|
+
[S/um] by the metre->Tidy3D length scale, exactly as the volumetric ``sigma_e``
|
|
549
|
+
path, so the exported Leontovich surface impedance
|
|
550
|
+
``Z_s(omega) = (1 - i) * sqrt(omega*mu0/(2*sigma))`` [ohm] matches maxwell's
|
|
551
|
+
``surface_impedance`` (ohms are unit-system independent). Tidy3D vector-fits
|
|
552
|
+
``Z_s(omega)`` over a required ``frequency_range``, so the export frequencies must be
|
|
553
|
+
supplied; a single operating frequency (the narrowband SIBC case) is widened into a
|
|
554
|
+
non-degenerate fit band around it.
|
|
555
|
+
"""
|
|
556
|
+
if not frequencies:
|
|
557
|
+
raise ValueError(
|
|
558
|
+
"LossyMetalMedium export needs the operating frequencies: Tidy3D fits the "
|
|
559
|
+
"surface impedance Z_s(omega) over a frequency_range, so pass frequencies=... "
|
|
560
|
+
"to Scene.to_tidy3d()."
|
|
561
|
+
)
|
|
562
|
+
freqs = tuple(float(frequency) for frequency in frequencies)
|
|
563
|
+
f_min, f_max = min(freqs), max(freqs)
|
|
564
|
+
if f_min == f_max:
|
|
565
|
+
f_min, f_max = 0.5 * f_min, 2.0 * f_max
|
|
566
|
+
return td.LossyMetalMedium(
|
|
567
|
+
conductivity=float(material.conductivity) / length_scale,
|
|
568
|
+
frequency_range=(f_min, f_max),
|
|
569
|
+
)
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def _uniform_grid_value(grid) -> float | None:
|
|
573
|
+
"""Single value of a per-cell parameter grid if spatially uniform, else None.
|
|
574
|
+
|
|
575
|
+
Custom dispersive poles and ``PerturbationMedium`` carry a 3D torch tensor of
|
|
576
|
+
per-cell oscillator strength / perturbation amplitude defined relative to the
|
|
577
|
+
owning structure's ``Box``. A spatially-uniform grid lowers to a homogeneous
|
|
578
|
+
Tidy3D medium; a varying grid would need a ``CustomMedium`` / ``CustomPoleResidue``
|
|
579
|
+
on a ``SpatialDataArray`` in absolute simulation coordinates, which the box-relative
|
|
580
|
+
grid cannot supply at material-conversion time.
|
|
581
|
+
"""
|
|
582
|
+
import torch
|
|
583
|
+
|
|
584
|
+
with torch.no_grad():
|
|
585
|
+
lo = float(grid.min())
|
|
586
|
+
hi = float(grid.max())
|
|
587
|
+
return lo if hi == lo else None
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def _homogenize_custom_poles(material):
|
|
591
|
+
"""Lower a Material whose dispersive poles are per-cell custom poles to scalar poles.
|
|
592
|
+
|
|
593
|
+
Each ``CustomPole`` whose strength grid is spatially uniform maps to its scalar
|
|
594
|
+
``reference_pole()`` (the peak equals the uniform value, so the lowering is exact);
|
|
595
|
+
a spatially-varying custom pole has no homogeneous Tidy3D equivalent and raises. The
|
|
596
|
+
reconstructed Material keeps every other channel (background permittivity/permeability,
|
|
597
|
+
conductivity, tensors, nonlinearity, modulation) so it re-enters ``_convert_material``
|
|
598
|
+
through the ordinary pole path.
|
|
599
|
+
"""
|
|
600
|
+
from ..media import CustomDrudePole, CustomPole, Material
|
|
601
|
+
|
|
602
|
+
def resolve(pole):
|
|
603
|
+
if not isinstance(pole, CustomPole):
|
|
604
|
+
return pole
|
|
605
|
+
grid = pole.plasma_frequency if isinstance(pole, CustomDrudePole) else pole.delta_eps
|
|
606
|
+
if _uniform_grid_value(grid) is None:
|
|
607
|
+
raise NotImplementedError(
|
|
608
|
+
"Tidy3D export of a spatially-varying custom dispersive pole would need a "
|
|
609
|
+
"CustomPoleResidue on a SpatialDataArray in absolute simulation coordinates, but "
|
|
610
|
+
"maxwell's per-cell pole-strength grid is defined relative to the owning structure's "
|
|
611
|
+
"Box and cannot be resolved into Tidy3D coordinates at material-conversion time. "
|
|
612
|
+
"Export a spatially-uniform custom pole (it lowers to the equivalent homogeneous "
|
|
613
|
+
"pole) or validate the per-cell profile against an FDTD/analytic reference."
|
|
614
|
+
)
|
|
615
|
+
return pole.reference_pole()
|
|
616
|
+
|
|
617
|
+
return Material(
|
|
618
|
+
eps_r=material.eps_r,
|
|
619
|
+
mu_r=material.mu_r,
|
|
620
|
+
sigma_e=material.sigma_e,
|
|
621
|
+
sigma_m=material.sigma_m,
|
|
622
|
+
debye_poles=tuple(resolve(p) for p in material.debye_poles),
|
|
623
|
+
drude_poles=tuple(resolve(p) for p in material.drude_poles),
|
|
624
|
+
lorentz_poles=tuple(resolve(p) for p in material.lorentz_poles),
|
|
625
|
+
mu_debye_poles=tuple(resolve(p) for p in material.mu_debye_poles),
|
|
626
|
+
mu_drude_poles=tuple(resolve(p) for p in material.mu_drude_poles),
|
|
627
|
+
mu_lorentz_poles=tuple(resolve(p) for p in material.mu_lorentz_poles),
|
|
628
|
+
epsilon_tensor=material.epsilon_tensor,
|
|
629
|
+
mu_tensor=material.mu_tensor,
|
|
630
|
+
sigma_e_tensor=material.sigma_e_tensor,
|
|
631
|
+
kerr_chi3=material.kerr_chi3,
|
|
632
|
+
nonlinearity=material.nonlinearity,
|
|
633
|
+
modulation=material.modulation,
|
|
634
|
+
)
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def _homogenize_perturbation(material):
|
|
638
|
+
"""Lower a ``PerturbationMedium`` with a spatially-uniform perturbation to a plain Material.
|
|
639
|
+
|
|
640
|
+
``eps(x) = eps_base + eps_sensitivity * perturbation(x)`` shifts the background
|
|
641
|
+
permittivity (``eps_inf``) only. A spatially-uniform perturbation is a constant shift
|
|
642
|
+
``eps_sensitivity * value``, applied to the scalar ``eps_r`` or, for a diagonal
|
|
643
|
+
``DiagonalTensor3`` base, to each principal axis; the base's poles / conductivity /
|
|
644
|
+
nonlinearity / modulation ride through unchanged so the reconstructed Material exports
|
|
645
|
+
exactly like its dispersive/anisotropic base at the shifted background. A spatially-
|
|
646
|
+
varying perturbation has no homogeneous Tidy3D equivalent and raises.
|
|
647
|
+
"""
|
|
648
|
+
from ..media import DiagonalTensor3, Material
|
|
649
|
+
|
|
650
|
+
value = _uniform_grid_value(material.perturbation)
|
|
651
|
+
if value is None:
|
|
652
|
+
raise NotImplementedError(
|
|
653
|
+
"Tidy3D export of a spatially-varying PerturbationMedium would need a CustomMedium on a "
|
|
654
|
+
"SpatialDataArray in absolute simulation coordinates, but its perturbation field is "
|
|
655
|
+
"defined relative to the owning structure's Box and cannot be resolved into Tidy3D "
|
|
656
|
+
"coordinates at material-conversion time. Export a spatially-uniform perturbation (it "
|
|
657
|
+
"lowers to a homogeneous permittivity shift eps_base + eps_sensitivity*value) or validate "
|
|
658
|
+
"the perturbed profile against an FDTD/analytic reference."
|
|
659
|
+
)
|
|
660
|
+
|
|
661
|
+
shift = float(material.eps_sensitivity) * value
|
|
662
|
+
base_tensor = material.epsilon_tensor
|
|
663
|
+
if isinstance(base_tensor, DiagonalTensor3):
|
|
664
|
+
eps_tensor = DiagonalTensor3(base_tensor.xx + shift, base_tensor.yy + shift, base_tensor.zz + shift)
|
|
665
|
+
eps_r = material.eps_r
|
|
666
|
+
else:
|
|
667
|
+
eps_tensor = None
|
|
668
|
+
eps_r = float(material.eps_r) + shift
|
|
669
|
+
|
|
670
|
+
return Material(
|
|
671
|
+
eps_r=eps_r,
|
|
672
|
+
mu_r=material.mu_r,
|
|
673
|
+
sigma_e=material.sigma_e,
|
|
674
|
+
sigma_m=material.sigma_m,
|
|
675
|
+
debye_poles=material.debye_poles,
|
|
676
|
+
drude_poles=material.drude_poles,
|
|
677
|
+
lorentz_poles=material.lorentz_poles,
|
|
678
|
+
mu_debye_poles=material.mu_debye_poles,
|
|
679
|
+
mu_drude_poles=material.mu_drude_poles,
|
|
680
|
+
mu_lorentz_poles=material.mu_lorentz_poles,
|
|
681
|
+
epsilon_tensor=eps_tensor,
|
|
682
|
+
mu_tensor=material.mu_tensor,
|
|
683
|
+
sigma_e_tensor=material.sigma_e_tensor,
|
|
684
|
+
kerr_chi3=material.kerr_chi3,
|
|
685
|
+
nonlinearity=material.nonlinearity,
|
|
686
|
+
modulation=material.modulation,
|
|
687
|
+
)
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def _convert_material(material, td, length_scale: float = _M_TO_UM, frequencies=None):
|
|
691
|
+
"""Convert a Maxwell Material to a Tidy3D medium."""
|
|
692
|
+
if getattr(material, "is_pec", False):
|
|
693
|
+
# A PEC marker has no finite permittivity (eps_r stays at its 1.0
|
|
694
|
+
# default). Without this early branch it would fall through to the
|
|
695
|
+
# non-dispersive td.Medium(permittivity=1.0) path and silently export
|
|
696
|
+
# as vacuum. Tidy3D models a perfect electric conductor with the
|
|
697
|
+
# dedicated PECMedium (eps_model -> -inf), not a finite dielectric.
|
|
698
|
+
return td.PECMedium()
|
|
699
|
+
if getattr(material, "is_medium2d", False):
|
|
700
|
+
return _medium2d(material, td, length_scale)
|
|
701
|
+
if getattr(material, "is_lossy_metal", False):
|
|
702
|
+
return _lossy_metal_medium(material, td, length_scale, frequencies)
|
|
703
|
+
if getattr(material, "perturbation", None) is not None:
|
|
704
|
+
# Resolve the background shift first: the lowered Material may still carry
|
|
705
|
+
# (custom) poles, which the recursion then handles through the pole path.
|
|
706
|
+
return _convert_material(_homogenize_perturbation(material), td, length_scale, frequencies=frequencies)
|
|
707
|
+
if getattr(material, "has_custom_poles", False):
|
|
708
|
+
return _convert_material(_homogenize_custom_poles(material), td, length_scale, frequencies=frequencies)
|
|
709
|
+
if material.is_magnetic_dispersive:
|
|
710
|
+
raise NotImplementedError(
|
|
711
|
+
"Tidy3D export for magnetic dispersive Material has no equivalent: Tidy3D has no "
|
|
712
|
+
"magnetic-material model. Its FDTD solver fixes the relative permeability at mu_r = 1 "
|
|
713
|
+
"and exposes no permeability pole (Debye/Drude/Lorentz) construct, so a frequency-"
|
|
714
|
+
"dispersive mu(omega) cannot be represented."
|
|
715
|
+
)
|
|
716
|
+
if not math.isclose(float(material.mu_r), 1.0, rel_tol=0.0, abs_tol=1.0e-12) or float(
|
|
717
|
+
getattr(material, "sigma_m", 0.0)
|
|
718
|
+
) != 0.0:
|
|
719
|
+
raise NotImplementedError(
|
|
720
|
+
"Tidy3D export currently assumes mu_r = 1 and no static magnetic conductivity "
|
|
721
|
+
"(sigma_m = 0): Tidy3D has no magnetic-material model. Its FDTD solver fixes the "
|
|
722
|
+
"relative permeability at 1 and models no magnetic loss, so a magnetic (mu_r != 1) "
|
|
723
|
+
"or magnetically-lossy (sigma_m != 0) medium has no Tidy3D equivalent."
|
|
724
|
+
)
|
|
725
|
+
|
|
726
|
+
modulation = getattr(material, "modulation", None)
|
|
727
|
+
if modulation is not None:
|
|
728
|
+
return _modulated_medium(material, modulation, td, length_scale)
|
|
729
|
+
|
|
730
|
+
if material.is_anisotropic:
|
|
731
|
+
return _anisotropic_medium(material, td, length_scale)
|
|
732
|
+
|
|
733
|
+
nonlinear_spec = _nonlinear_spec(material, td, length_scale)
|
|
734
|
+
sigma_e = float(getattr(material, "sigma_e", 0.0))
|
|
735
|
+
|
|
736
|
+
if not material.is_dispersive:
|
|
737
|
+
kwargs = {"permittivity": material.eps_r}
|
|
738
|
+
if sigma_e != 0.0:
|
|
739
|
+
# Tidy3D works in micrometres, so its Medium.conductivity is [S/um].
|
|
740
|
+
# Convert the SI conductivity [S/m] by the metre->Tidy3D length scale so
|
|
741
|
+
# the exported eps'' = sigma/(w*eps0) matches the physical value.
|
|
742
|
+
kwargs["conductivity"] = sigma_e / length_scale
|
|
743
|
+
if nonlinear_spec is not None:
|
|
744
|
+
kwargs["nonlinear_spec"] = nonlinear_spec
|
|
745
|
+
return td.Medium(**kwargs)
|
|
746
|
+
|
|
747
|
+
if sigma_e == 0.0:
|
|
748
|
+
return _dispersive_medium(material, td, nonlinear_spec)
|
|
749
|
+
|
|
750
|
+
# Static electric conductivity combined with a dispersive pole model. Tidy3D's
|
|
751
|
+
# specialized Drude/Lorentz/Debye media cannot carry a conductivity, so fold the
|
|
752
|
+
# dispersion and the conductivity into a single PoleResidue: convert the
|
|
753
|
+
# dispersive medium to its equivalent pole-residue form and append the
|
|
754
|
+
# zero-frequency conductivity pole. Any Kerr/TPA nonlinearity rides along on the
|
|
755
|
+
# same PoleResidue.
|
|
756
|
+
base = _dispersive_medium(material, td).pole_residue
|
|
757
|
+
poleres_kwargs = dict(
|
|
758
|
+
eps_inf=base.eps_inf,
|
|
759
|
+
poles=tuple(base.poles) + (_conductivity_pole(sigma_e),),
|
|
760
|
+
)
|
|
761
|
+
if nonlinear_spec is not None:
|
|
762
|
+
poleres_kwargs["nonlinear_spec"] = nonlinear_spec
|
|
763
|
+
return td.PoleResidue(**poleres_kwargs)
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
# ---------------------------------------------------------------------------
|
|
767
|
+
# Geometry conversion (all lengths scaled)
|
|
768
|
+
# ---------------------------------------------------------------------------
|
|
769
|
+
|
|
770
|
+
def _triangle_mesh(geometry, td, s):
|
|
771
|
+
"""Tessellate a maxwell SDF primitive into a Tidy3D ``TriangleMesh``.
|
|
772
|
+
|
|
773
|
+
Every ``witwin.core`` primitive exposes ``to_mesh() -> (vertices, faces)`` with the
|
|
774
|
+
structure's position/rotation already baked into the (metre) vertices, so the mesh is
|
|
775
|
+
in absolute world coordinates. Scaling the vertices by the metre->Tidy3D length factor
|
|
776
|
+
(leaving the integer face table untouched) gives the exact same watertight surface
|
|
777
|
+
Tidy3D voxelizes onto its Yee grid, so a primitive that has no analytic Tidy3D
|
|
778
|
+
counterpart (torus, pyramid, prism, hollow box, general ellipsoid) still round-trips
|
|
779
|
+
geometrically rather than being rejected.
|
|
780
|
+
"""
|
|
781
|
+
import torch # local import, matching the lazy-torch convention in this module
|
|
782
|
+
|
|
783
|
+
vertices, faces = geometry.to_mesh()
|
|
784
|
+
vertices_np = vertices.detach().cpu().to(torch.float64).numpy() * float(s)
|
|
785
|
+
faces_np = faces.detach().cpu().to(torch.int64).numpy()
|
|
786
|
+
return td.TriangleMesh.from_vertices_faces(vertices_np, faces_np)
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
def _poly_slab(geometry, td, s):
|
|
790
|
+
"""Convert an untransformed simple ``PolySlab`` to a Tidy3D ``PolySlab``.
|
|
791
|
+
|
|
792
|
+
Tidy3D's ``PolySlab`` carries the transverse polygon plus the axial ``slab_bounds`` in
|
|
793
|
+
absolute simulation coordinates and applies no position/rotation, so the faithful
|
|
794
|
+
primitive mapping is exact only when the maxwell slab is un-rotated, centred at the
|
|
795
|
+
origin, and vertical (``sidewall_angle == 0``). The polygon vertices and slab bounds
|
|
796
|
+
scale by the metre->Tidy3D length factor; ``reference_plane`` carries across verbatim.
|
|
797
|
+
A tapered, rotated, or offset slab is baked into a ``TriangleMesh`` instead (its
|
|
798
|
+
``to_mesh`` already applies the taper and transform), so no geometry is silently lost.
|
|
799
|
+
"""
|
|
800
|
+
import torch # local import, matching the lazy-torch convention in this module
|
|
801
|
+
|
|
802
|
+
rotation = getattr(geometry, "rotation", None)
|
|
803
|
+
if rotation is None:
|
|
804
|
+
is_identity_rotation = True
|
|
805
|
+
else:
|
|
806
|
+
# GeometryBase stores rotation as a unit quaternion; the identity is (1, 0, 0, 0).
|
|
807
|
+
quat = [float(v) for v in rotation.detach().cpu().tolist()]
|
|
808
|
+
is_identity_rotation = abs(quat[0] - 1.0) < 1e-12 and all(abs(c) < 1e-12 for c in quat[1:])
|
|
809
|
+
position = [float(v) for v in geometry.position.detach().cpu().tolist()]
|
|
810
|
+
sidewall = float(geometry.sidewall_angle.detach().cpu().item())
|
|
811
|
+
is_simple = (
|
|
812
|
+
is_identity_rotation
|
|
813
|
+
and all(abs(component) < 1e-12 for component in position)
|
|
814
|
+
and abs(sidewall) < 1e-12
|
|
815
|
+
)
|
|
816
|
+
if not is_simple:
|
|
817
|
+
return _triangle_mesh(geometry, td, s)
|
|
818
|
+
|
|
819
|
+
vertices = geometry.vertices.detach().cpu().to(torch.float64).numpy() * float(s)
|
|
820
|
+
lo, hi = geometry.bounds.detach().cpu().to(torch.float64).tolist()
|
|
821
|
+
return td.PolySlab(
|
|
822
|
+
vertices=[(float(u), float(v)) for u, v in vertices],
|
|
823
|
+
slab_bounds=(lo * float(s), hi * float(s)),
|
|
824
|
+
axis=_axis_name_to_index(geometry.axis),
|
|
825
|
+
reference_plane=geometry.reference_plane,
|
|
826
|
+
)
|
|
827
|
+
|
|
828
|
+
|
|
829
|
+
def _convert_geometry(geometry, td, s):
|
|
830
|
+
"""Convert maxwell geometry to a Tidy3D geometry (lengths x *s*)."""
|
|
831
|
+
kind = geometry.kind
|
|
832
|
+
|
|
833
|
+
if kind == "box":
|
|
834
|
+
return td.Box(center=_scale3(geometry.position, s), size=_scale3(geometry.size, s))
|
|
835
|
+
|
|
836
|
+
if kind == "sphere":
|
|
837
|
+
return td.Sphere(center=_scale3(geometry.position, s), radius=float(geometry.radius) * s)
|
|
838
|
+
|
|
839
|
+
if kind == "cylinder":
|
|
840
|
+
axis_idx = _axis_name_to_index(geometry.axis)
|
|
841
|
+
return td.Cylinder(
|
|
842
|
+
center=_scale3(geometry.position, s),
|
|
843
|
+
radius=float(geometry.radius) * s,
|
|
844
|
+
length=float(geometry.height) * s,
|
|
845
|
+
axis=axis_idx,
|
|
846
|
+
)
|
|
847
|
+
|
|
848
|
+
if kind == "cone":
|
|
849
|
+
axis_idx = _axis_name_to_index(geometry.axis)
|
|
850
|
+
sidewall_angle = math.atan2(float(geometry.radius), float(geometry.height))
|
|
851
|
+
return td.Cylinder(
|
|
852
|
+
center=_scale3(geometry.position, s),
|
|
853
|
+
radius=float(geometry.radius) * s,
|
|
854
|
+
length=float(geometry.height) * s,
|
|
855
|
+
axis=axis_idx,
|
|
856
|
+
sidewall_angle=sidewall_angle,
|
|
857
|
+
)
|
|
858
|
+
|
|
859
|
+
if kind == "ellipsoid":
|
|
860
|
+
rx, ry, rz = float(geometry.radii[0]), float(geometry.radii[1]), float(geometry.radii[2])
|
|
861
|
+
if abs(rx - ry) < 1e-12 and abs(ry - rz) < 1e-12:
|
|
862
|
+
# An isotropic ellipsoid is exactly a sphere; use Tidy3D's analytic
|
|
863
|
+
# Sphere rather than a tessellated approximation.
|
|
864
|
+
return td.Sphere(center=_scale3(geometry.position, s), radius=rx * s)
|
|
865
|
+
return _triangle_mesh(geometry, td, s)
|
|
866
|
+
|
|
867
|
+
if kind == "poly_slab":
|
|
868
|
+
return _poly_slab(geometry, td, s)
|
|
869
|
+
|
|
870
|
+
if hasattr(geometry, "to_mesh"):
|
|
871
|
+
# Any remaining primitive with no analytic Tidy3D equivalent (torus, pyramid,
|
|
872
|
+
# prism, hollow box) tessellates to a TriangleMesh from its own surface mesh.
|
|
873
|
+
return _triangle_mesh(geometry, td, s)
|
|
874
|
+
|
|
875
|
+
raise NotImplementedError(
|
|
876
|
+
f"Geometry type '{kind}' exposes no surface mesh, so it has no Tidy3D geometry mapping. "
|
|
877
|
+
f"Analytic primitives (box, sphere, cylinder, cone) and any mesh-able primitive are supported."
|
|
878
|
+
)
|
|
879
|
+
|
|
880
|
+
|
|
881
|
+
# ---------------------------------------------------------------------------
|
|
882
|
+
# Structure conversion
|
|
883
|
+
# ---------------------------------------------------------------------------
|
|
884
|
+
|
|
885
|
+
def _convert_structure(structure, td, s, frequencies=None):
|
|
886
|
+
"""Convert maxwell Structure to a Tidy3D Structure."""
|
|
887
|
+
td_geometry = _convert_geometry(structure.geometry, td, s)
|
|
888
|
+
td_material = _convert_material(structure.material, td, s, frequencies=frequencies)
|
|
889
|
+
return td.Structure(geometry=td_geometry, medium=td_material)
|
|
890
|
+
|
|
891
|
+
|
|
892
|
+
# ---------------------------------------------------------------------------
|
|
893
|
+
# Source conversion
|
|
894
|
+
# ---------------------------------------------------------------------------
|
|
895
|
+
|
|
896
|
+
def _tfsf_source(source, scene, td, s):
|
|
897
|
+
"""Convert a total-field/scattered-field-injected plane wave to a Tidy3D ``TFSF``.
|
|
898
|
+
|
|
899
|
+
Tidy3D models a TFSF injection as a dedicated ``td.TFSF`` volume source whose box is the
|
|
900
|
+
total-field region, ``injection_axis`` is the propagation axis, and ``direction`` its sign;
|
|
901
|
+
the incident wave enters the low face of that axis (``+``) or the high face (``-``). maxwell
|
|
902
|
+
carries the same information on the ``TFSF`` injection object: a ``box`` mode gives the full
|
|
903
|
+
(x, y, z) total-field bounds, while a ``slab`` mode bounds only the propagation axis and is
|
|
904
|
+
infinite in the two transverse directions (a 1D total-field layer). The plane-wave angles reuse
|
|
905
|
+
the same ``_direction_to_angles`` mapping as the soft plane wave, so a TFSF and a soft plane
|
|
906
|
+
wave of identical direction inject the same incident field.
|
|
907
|
+
"""
|
|
908
|
+
from ..sources import TFSF
|
|
909
|
+
|
|
910
|
+
injection = source.injection
|
|
911
|
+
# Convert Maxwell's physical V/m incident-field amplitude to the
|
|
912
|
+
# multiplier on Tidy3D's fixed 1 W/um^2 TFSF normalization.
|
|
913
|
+
td_source_time = _convert_source_time(
|
|
914
|
+
source.source_time,
|
|
915
|
+
td,
|
|
916
|
+
amplitude_scale=1.0 / (s * _TIDY3D_TFSF_UNIT_FIELD),
|
|
917
|
+
)
|
|
918
|
+
direction = source.direction
|
|
919
|
+
injection_axis = resolve_injection_axis(direction, source.injection_axis)
|
|
920
|
+
axis_idx = _axis_name_to_index(injection_axis)
|
|
921
|
+
inject_dir = "+" if direction[axis_idx] > 0 else "-"
|
|
922
|
+
pol_angle, angle_theta, angle_phi = _direction_to_angles(
|
|
923
|
+
direction, axis_idx, source.polarization
|
|
924
|
+
)
|
|
925
|
+
|
|
926
|
+
if injection.mode == "slab":
|
|
927
|
+
lo, hi = injection.axis_bounds
|
|
928
|
+
center = list((b_lo + b_hi) / 2.0 * s for b_lo, b_hi in scene.domain.bounds)
|
|
929
|
+
size = [td.inf, td.inf, td.inf]
|
|
930
|
+
center[axis_idx] = 0.5 * (lo + hi) * s
|
|
931
|
+
size[axis_idx] = (hi - lo) * s
|
|
932
|
+
else:
|
|
933
|
+
bounds = injection.bounds
|
|
934
|
+
center = [0.5 * (lo + hi) * s for lo, hi in bounds]
|
|
935
|
+
size = [(hi - lo) * s for lo, hi in bounds]
|
|
936
|
+
|
|
937
|
+
return td.TFSF(
|
|
938
|
+
center=tuple(center),
|
|
939
|
+
size=tuple(size),
|
|
940
|
+
source_time=td_source_time,
|
|
941
|
+
direction=inject_dir,
|
|
942
|
+
injection_axis=axis_idx,
|
|
943
|
+
pol_angle=pol_angle,
|
|
944
|
+
angle_theta=angle_theta,
|
|
945
|
+
angle_phi=angle_phi,
|
|
946
|
+
name=source.name or "tfsf",
|
|
947
|
+
)
|
|
948
|
+
|
|
949
|
+
|
|
950
|
+
def _custom_source_dataset(dataset, td, freq, s, component_map):
|
|
951
|
+
"""Build a Tidy3D field/current dataset from a maxwell rectilinear dataset.
|
|
952
|
+
|
|
953
|
+
Tidy3D custom field/current sources carry a ``FieldDataset`` of ``ScalarFieldDataArray``
|
|
954
|
+
components on coordinates ``(x, y, z, f)`` that are **relative to the source center** and a
|
|
955
|
+
single frequency in the source band. maxwell stores absolute-metre coordinates and a
|
|
956
|
+
time-domain spatial profile, so the center is the midpoint of each coordinate span, the
|
|
957
|
+
exported coordinates are ``(coord - center) * length_scale`` (um, relative), the frequency
|
|
958
|
+
axis is the single injection frequency, and ``component_map`` renames maxwell components
|
|
959
|
+
(identity for field sources; ``J -> E`` / ``M -> H`` for current sources, matching Tidy3D's
|
|
960
|
+
convention that the current dataset reuses ``E``/``H`` slots for ``J``/``M``). Returns the
|
|
961
|
+
Tidy3D dataset together with the source center and size in Tidy3D units.
|
|
962
|
+
"""
|
|
963
|
+
import numpy as _np
|
|
964
|
+
|
|
965
|
+
coords_m = dataset.coords
|
|
966
|
+
center_m = tuple(0.5 * (float(axis[0]) + float(axis[-1])) for axis in coords_m)
|
|
967
|
+
size_um = tuple((float(axis[-1]) - float(axis[0])) * s for axis in coords_m)
|
|
968
|
+
rel_coords = tuple(
|
|
969
|
+
_np.asarray([(float(v) - center_m[i]) * s for v in coords_m[i]], dtype=_np.float64)
|
|
970
|
+
for i in range(3)
|
|
971
|
+
)
|
|
972
|
+
freqs = _np.asarray([float(freq)], dtype=_np.float64)
|
|
973
|
+
td_components = {}
|
|
974
|
+
for source_name, values in dataset.components.items():
|
|
975
|
+
td_name = component_map[source_name]
|
|
976
|
+
data = _np.asarray(values, dtype=_np.complex128)[..., _np.newaxis]
|
|
977
|
+
td_components[td_name] = td.ScalarFieldDataArray(
|
|
978
|
+
data, coords=dict(x=rel_coords[0], y=rel_coords[1], z=rel_coords[2], f=freqs)
|
|
979
|
+
)
|
|
980
|
+
return td.FieldDataset(**td_components), tuple(c * s for c in center_m), size_um
|
|
981
|
+
|
|
982
|
+
|
|
983
|
+
def _custom_source_frequency(source, frequencies):
|
|
984
|
+
"""Single injection frequency for a custom current/field source export.
|
|
985
|
+
|
|
986
|
+
Tidy3D requires the custom dataset to carry exactly one frequency inside the source band. It
|
|
987
|
+
is taken from the attached ``source_time`` (its characteristic frequency), falling back to the
|
|
988
|
+
first export frequency when the source has no explicit waveform.
|
|
989
|
+
"""
|
|
990
|
+
source_time = source.source_time
|
|
991
|
+
if source_time is not None:
|
|
992
|
+
return float(source_time.frequency)
|
|
993
|
+
if frequencies:
|
|
994
|
+
return float(frequencies[0])
|
|
995
|
+
raise ValueError(
|
|
996
|
+
f"Custom source '{source.name}' export needs an injection frequency: attach a source_time "
|
|
997
|
+
"or pass frequencies=... to Scene.to_tidy3d()."
|
|
998
|
+
)
|
|
999
|
+
|
|
1000
|
+
|
|
1001
|
+
def _convert_source(source, scene, td, s, frequencies=None):
|
|
1002
|
+
"""Convert a maxwell source to a Tidy3D source (lengths x *s*)."""
|
|
1003
|
+
from ..sources import (
|
|
1004
|
+
AstigmaticGaussianBeam,
|
|
1005
|
+
CustomCurrentSource,
|
|
1006
|
+
CustomFieldSource,
|
|
1007
|
+
GaussianBeam,
|
|
1008
|
+
ModeSource,
|
|
1009
|
+
PlaneWave,
|
|
1010
|
+
PointDipole,
|
|
1011
|
+
TFSF,
|
|
1012
|
+
UniformCurrentSource,
|
|
1013
|
+
)
|
|
1014
|
+
domain_bounds = scene.domain.bounds
|
|
1015
|
+
|
|
1016
|
+
if isinstance(source, PointDipole):
|
|
1017
|
+
component = _polarization_to_component(source.polarization)
|
|
1018
|
+
# Maxwell's SI point-source amplitude is an electric current moment in A*m;
|
|
1019
|
+
# Tidy3D's micrometre coordinate system uses A*um.
|
|
1020
|
+
td_source_time = _convert_source_time(source.source_time, td, amplitude_scale=s)
|
|
1021
|
+
return td.PointDipole(
|
|
1022
|
+
center=_scale3(source.position, s),
|
|
1023
|
+
source_time=td_source_time,
|
|
1024
|
+
polarization=component,
|
|
1025
|
+
name=source.name or "point_dipole",
|
|
1026
|
+
)
|
|
1027
|
+
|
|
1028
|
+
if isinstance(source, PlaneWave):
|
|
1029
|
+
if isinstance(source.injection, TFSF):
|
|
1030
|
+
return _tfsf_source(source, scene, td, s)
|
|
1031
|
+
td_source_time = _convert_source_time(source.source_time, td)
|
|
1032
|
+
direction = source.direction
|
|
1033
|
+
|
|
1034
|
+
injection_axis = resolve_injection_axis(direction, source.injection_axis)
|
|
1035
|
+
axis_idx = _axis_name_to_index(injection_axis)
|
|
1036
|
+
inject_dir = "+" if direction[axis_idx] > 0 else "-"
|
|
1037
|
+
|
|
1038
|
+
center = list((lo + hi) / 2.0 * s for lo, hi in domain_bounds)
|
|
1039
|
+
size = [td.inf, td.inf, td.inf]
|
|
1040
|
+
center[axis_idx] = soft_plane_wave_coordinate(scene, injection_axis, float(direction[axis_idx])) * s
|
|
1041
|
+
size[axis_idx] = 0.0
|
|
1042
|
+
|
|
1043
|
+
pol_angle, angle_theta, angle_phi = _direction_to_angles(
|
|
1044
|
+
direction, axis_idx, source.polarization
|
|
1045
|
+
)
|
|
1046
|
+
|
|
1047
|
+
return td.PlaneWave(
|
|
1048
|
+
center=tuple(center),
|
|
1049
|
+
size=tuple(size),
|
|
1050
|
+
source_time=td_source_time,
|
|
1051
|
+
direction=inject_dir,
|
|
1052
|
+
pol_angle=pol_angle,
|
|
1053
|
+
angle_theta=angle_theta,
|
|
1054
|
+
angle_phi=angle_phi,
|
|
1055
|
+
name=source.name or "plane_wave",
|
|
1056
|
+
)
|
|
1057
|
+
|
|
1058
|
+
if isinstance(source, (GaussianBeam, AstigmaticGaussianBeam)):
|
|
1059
|
+
if isinstance(source.injection, TFSF):
|
|
1060
|
+
return _tfsf_source(source, scene, td, s)
|
|
1061
|
+
td_source_time = _convert_source_time(source.source_time, td)
|
|
1062
|
+
direction = source.direction
|
|
1063
|
+
|
|
1064
|
+
abs_dir = [abs(d) for d in direction]
|
|
1065
|
+
dominant_axis = int(np.argmax(abs_dir))
|
|
1066
|
+
inject_dir = "+" if direction[dominant_axis] > 0 else "-"
|
|
1067
|
+
|
|
1068
|
+
source_plane = soft_plane_wave_coordinate(
|
|
1069
|
+
scene, "xyz"[dominant_axis], float(direction[dominant_axis])
|
|
1070
|
+
)
|
|
1071
|
+
source_axis_parameter = (
|
|
1072
|
+
source_plane - float(source.focus[dominant_axis])
|
|
1073
|
+
) / float(direction[dominant_axis])
|
|
1074
|
+
center_m = [
|
|
1075
|
+
float(source.focus[index]) + source_axis_parameter * float(direction[index])
|
|
1076
|
+
for index in range(3)
|
|
1077
|
+
]
|
|
1078
|
+
center = [value * s for value in center_m]
|
|
1079
|
+
size = [(hi - lo) * s for lo, hi in domain_bounds]
|
|
1080
|
+
size[dominant_axis] = 0.0
|
|
1081
|
+
|
|
1082
|
+
pol_angle, angle_theta, angle_phi = _direction_to_angles(
|
|
1083
|
+
direction, dominant_axis, source.polarization
|
|
1084
|
+
)
|
|
1085
|
+
|
|
1086
|
+
if isinstance(source, AstigmaticGaussianBeam):
|
|
1087
|
+
return td.AstigmaticGaussianBeam(
|
|
1088
|
+
center=tuple(center),
|
|
1089
|
+
size=tuple(size),
|
|
1090
|
+
source_time=td_source_time,
|
|
1091
|
+
direction=inject_dir,
|
|
1092
|
+
pol_angle=pol_angle,
|
|
1093
|
+
angle_theta=angle_theta,
|
|
1094
|
+
angle_phi=angle_phi,
|
|
1095
|
+
waist_sizes=(source.beam_waist[0] * s, source.beam_waist[1] * s),
|
|
1096
|
+
waist_distances=(
|
|
1097
|
+
(source_axis_parameter - source.focus_u) * s,
|
|
1098
|
+
(source_axis_parameter - source.focus_v) * s,
|
|
1099
|
+
),
|
|
1100
|
+
name=source.name or "astigmatic_gaussian_beam",
|
|
1101
|
+
)
|
|
1102
|
+
|
|
1103
|
+
return td.GaussianBeam(
|
|
1104
|
+
center=tuple(center),
|
|
1105
|
+
size=tuple(size),
|
|
1106
|
+
source_time=td_source_time,
|
|
1107
|
+
direction=inject_dir,
|
|
1108
|
+
pol_angle=pol_angle,
|
|
1109
|
+
angle_theta=angle_theta,
|
|
1110
|
+
angle_phi=angle_phi,
|
|
1111
|
+
waist_radius=source.beam_waist * s,
|
|
1112
|
+
waist_distance=source_axis_parameter * s,
|
|
1113
|
+
name=source.name or "gaussian_beam",
|
|
1114
|
+
)
|
|
1115
|
+
|
|
1116
|
+
if isinstance(source, ModeSource):
|
|
1117
|
+
td_source_time = _convert_source_time(source.source_time, td)
|
|
1118
|
+
axis_idx = _axis_name_to_index(source.normal_axis)
|
|
1119
|
+
center = list(_scale3(source.position, s))
|
|
1120
|
+
size = list(_scale3(source.size, s))
|
|
1121
|
+
size[axis_idx] = 0.0
|
|
1122
|
+
return td.ModeSource(
|
|
1123
|
+
center=tuple(center),
|
|
1124
|
+
size=tuple(size),
|
|
1125
|
+
source_time=td_source_time,
|
|
1126
|
+
direction=source.direction,
|
|
1127
|
+
mode_spec=td.ModeSpec(
|
|
1128
|
+
num_modes=_mode_candidate_count(source.mode_index),
|
|
1129
|
+
sort_spec=_mode_sort_spec(td, source.normal_axis, source.polarization),
|
|
1130
|
+
),
|
|
1131
|
+
mode_index=int(source.mode_index),
|
|
1132
|
+
name=source.name or "mode_source",
|
|
1133
|
+
)
|
|
1134
|
+
|
|
1135
|
+
if isinstance(source, UniformCurrentSource):
|
|
1136
|
+
# Current density is A/m^2 in Maxwell and A/um^2 in Tidy3D.
|
|
1137
|
+
td_source_time = _convert_source_time(source.source_time, td, amplitude_scale=1.0 / s**2)
|
|
1138
|
+
return td.UniformCurrentSource(
|
|
1139
|
+
center=_scale3(source.center, s),
|
|
1140
|
+
size=_scale3(source.size, s),
|
|
1141
|
+
source_time=td_source_time,
|
|
1142
|
+
polarization=_polarization_to_component(source.polarization),
|
|
1143
|
+
name=source.name or "uniform_current",
|
|
1144
|
+
)
|
|
1145
|
+
|
|
1146
|
+
if isinstance(source, CustomFieldSource):
|
|
1147
|
+
# Dataset E/H fields are SI V/m and A/m; Tidy3D stores them per micrometre.
|
|
1148
|
+
td_source_time = _convert_source_time(source.source_time, td, amplitude_scale=1.0 / s)
|
|
1149
|
+
freq = _custom_source_frequency(source, frequencies)
|
|
1150
|
+
identity = {name: name for name in ("Ex", "Ey", "Ez", "Hx", "Hy", "Hz")}
|
|
1151
|
+
field_dataset, center, size = _custom_source_dataset(
|
|
1152
|
+
source.field_dataset, td, freq, s, identity
|
|
1153
|
+
)
|
|
1154
|
+
return td.CustomFieldSource(
|
|
1155
|
+
center=center,
|
|
1156
|
+
size=size,
|
|
1157
|
+
source_time=td_source_time,
|
|
1158
|
+
field_dataset=field_dataset,
|
|
1159
|
+
name=source.name or "custom_field",
|
|
1160
|
+
)
|
|
1161
|
+
|
|
1162
|
+
if isinstance(source, CustomCurrentSource):
|
|
1163
|
+
# Electric and magnetic volume-current densities both acquire two inverse-length factors.
|
|
1164
|
+
td_source_time = _convert_source_time(source.source_time, td, amplitude_scale=1.0 / s**2)
|
|
1165
|
+
freq = _custom_source_frequency(source, frequencies)
|
|
1166
|
+
current_map = {"Jx": "Ex", "Jy": "Ey", "Jz": "Ez", "Mx": "Hx", "My": "Hy", "Mz": "Hz"}
|
|
1167
|
+
current_dataset, center, size = _custom_source_dataset(
|
|
1168
|
+
source.current_dataset, td, freq, s, current_map
|
|
1169
|
+
)
|
|
1170
|
+
return td.CustomCurrentSource(
|
|
1171
|
+
center=center,
|
|
1172
|
+
size=size,
|
|
1173
|
+
source_time=td_source_time,
|
|
1174
|
+
current_dataset=current_dataset,
|
|
1175
|
+
name=source.name or "custom_current",
|
|
1176
|
+
)
|
|
1177
|
+
|
|
1178
|
+
raise NotImplementedError(
|
|
1179
|
+
f"Source type '{type(source).__name__}' has no Tidy3D source mapping."
|
|
1180
|
+
)
|
|
1181
|
+
|
|
1182
|
+
|
|
1183
|
+
def _direction_to_angles(direction, dominant_axis, polarization=None):
|
|
1184
|
+
"""Compute Tidy3D source angles from a propagation and polarization vector.
|
|
1185
|
+
|
|
1186
|
+
Tidy3D defines its angles in a local frame whose normal is the injection
|
|
1187
|
+
axis. Its ``direction='-'`` option reverses the complete propagation vector,
|
|
1188
|
+
while ``pol_angle`` rotates the electric field from the local P basis toward
|
|
1189
|
+
the S basis. Expressing both Maxwell vectors in that frame preserves
|
|
1190
|
+
arbitrary transverse polarization for every injection axis and sign.
|
|
1191
|
+
"""
|
|
1192
|
+
direction_vector = np.asarray(direction, dtype=np.float64)
|
|
1193
|
+
norm = float(np.linalg.norm(direction_vector))
|
|
1194
|
+
if norm < 1e-15:
|
|
1195
|
+
return 0.0, 0.0, 0.0
|
|
1196
|
+
direction_vector /= norm
|
|
1197
|
+
|
|
1198
|
+
tangential_axes = [axis for axis in range(3) if axis != dominant_axis]
|
|
1199
|
+
direction_sign = 1.0 if direction_vector[dominant_axis] >= 0.0 else -1.0
|
|
1200
|
+
local_direction = np.array(
|
|
1201
|
+
(
|
|
1202
|
+
direction_vector[tangential_axes[0]] / direction_sign,
|
|
1203
|
+
direction_vector[tangential_axes[1]] / direction_sign,
|
|
1204
|
+
direction_vector[dominant_axis] / direction_sign,
|
|
1205
|
+
),
|
|
1206
|
+
dtype=np.float64,
|
|
1207
|
+
)
|
|
1208
|
+
angle_theta = math.acos(float(np.clip(local_direction[2], -1.0, 1.0)))
|
|
1209
|
+
if math.sin(angle_theta) < 1e-12:
|
|
1210
|
+
angle_phi = 0.0
|
|
1211
|
+
else:
|
|
1212
|
+
angle_phi = math.atan2(float(local_direction[1]), float(local_direction[0]))
|
|
1213
|
+
|
|
1214
|
+
if polarization is None:
|
|
1215
|
+
return 0.0, angle_theta, angle_phi
|
|
1216
|
+
|
|
1217
|
+
cos_theta = math.cos(angle_theta)
|
|
1218
|
+
sin_theta = math.sin(angle_theta)
|
|
1219
|
+
cos_phi = math.cos(angle_phi)
|
|
1220
|
+
sin_phi = math.sin(angle_phi)
|
|
1221
|
+
p_basis_local = np.array(
|
|
1222
|
+
(cos_theta * cos_phi, cos_theta * sin_phi, -sin_theta),
|
|
1223
|
+
dtype=np.float64,
|
|
1224
|
+
)
|
|
1225
|
+
s_basis_local = np.array((-sin_phi, cos_phi, 0.0), dtype=np.float64)
|
|
1226
|
+
|
|
1227
|
+
def _to_global(local_vector):
|
|
1228
|
+
global_vector = np.empty(3, dtype=np.float64)
|
|
1229
|
+
global_vector[dominant_axis] = local_vector[2]
|
|
1230
|
+
global_vector[tangential_axes[0]] = local_vector[0]
|
|
1231
|
+
global_vector[tangential_axes[1]] = local_vector[1]
|
|
1232
|
+
return global_vector
|
|
1233
|
+
|
|
1234
|
+
polarization_vector = np.asarray(polarization, dtype=np.float64)
|
|
1235
|
+
polarization_norm = float(np.linalg.norm(polarization_vector))
|
|
1236
|
+
if polarization_norm < 1e-15:
|
|
1237
|
+
return 0.0, angle_theta, angle_phi
|
|
1238
|
+
polarization_vector /= polarization_norm
|
|
1239
|
+
p_basis = _to_global(p_basis_local)
|
|
1240
|
+
s_basis = _to_global(s_basis_local)
|
|
1241
|
+
pol_angle = math.atan2(
|
|
1242
|
+
float(np.dot(polarization_vector, s_basis)),
|
|
1243
|
+
float(np.dot(polarization_vector, p_basis)),
|
|
1244
|
+
)
|
|
1245
|
+
return pol_angle, angle_theta, angle_phi
|
|
1246
|
+
|
|
1247
|
+
|
|
1248
|
+
# ---------------------------------------------------------------------------
|
|
1249
|
+
# Monitor conversion
|
|
1250
|
+
# ---------------------------------------------------------------------------
|
|
1251
|
+
|
|
1252
|
+
def _plane_center_size(axis_idx, plane_position, domain_bounds, s):
|
|
1253
|
+
"""(center, size) of a full-domain plane at *plane_position* along *axis_idx*."""
|
|
1254
|
+
center = list((lo + hi) / 2.0 * s for lo, hi in domain_bounds)
|
|
1255
|
+
size = [(hi - lo) * s for lo, hi in domain_bounds]
|
|
1256
|
+
center[axis_idx] = float(plane_position) * s
|
|
1257
|
+
size[axis_idx] = 0.0
|
|
1258
|
+
return center, size
|
|
1259
|
+
|
|
1260
|
+
|
|
1261
|
+
def _convert_monitor(monitor, domain_bounds, frequencies, td, s):
|
|
1262
|
+
"""Convert a maxwell monitor to a Tidy3D monitor (lengths x *s*)."""
|
|
1263
|
+
from ..monitors import (
|
|
1264
|
+
DiffractionMonitor,
|
|
1265
|
+
FieldTimeMonitor,
|
|
1266
|
+
FinitePlaneMonitor,
|
|
1267
|
+
FluxTimeMonitor,
|
|
1268
|
+
ModeMonitor,
|
|
1269
|
+
PermittivityMonitor,
|
|
1270
|
+
PlaneMonitor,
|
|
1271
|
+
PointMonitor,
|
|
1272
|
+
)
|
|
1273
|
+
|
|
1274
|
+
# Time-domain monitors sample the running field, not a frequency spectrum, so they
|
|
1275
|
+
# are converted before the frequency requirement below.
|
|
1276
|
+
if isinstance(monitor, FieldTimeMonitor):
|
|
1277
|
+
return td.FieldTimeMonitor(
|
|
1278
|
+
center=_scale3(monitor.position, s),
|
|
1279
|
+
size=_scale3(monitor.size, s),
|
|
1280
|
+
start=monitor.start,
|
|
1281
|
+
stop=monitor.stop,
|
|
1282
|
+
interval=monitor.interval,
|
|
1283
|
+
fields=list(monitor.components),
|
|
1284
|
+
name=monitor.name,
|
|
1285
|
+
)
|
|
1286
|
+
|
|
1287
|
+
if isinstance(monitor, FluxTimeMonitor):
|
|
1288
|
+
axis_idx = _axis_name_to_index(monitor.axis)
|
|
1289
|
+
center, size = _plane_center_size(axis_idx, monitor.position, domain_bounds, s)
|
|
1290
|
+
return td.FluxTimeMonitor(
|
|
1291
|
+
center=tuple(center),
|
|
1292
|
+
size=tuple(size),
|
|
1293
|
+
start=monitor.start,
|
|
1294
|
+
stop=monitor.stop,
|
|
1295
|
+
interval=monitor.interval,
|
|
1296
|
+
normal_dir=monitor.normal_direction,
|
|
1297
|
+
name=monitor.name,
|
|
1298
|
+
)
|
|
1299
|
+
|
|
1300
|
+
monitor_frequencies = (
|
|
1301
|
+
monitor.frequencies
|
|
1302
|
+
if hasattr(monitor, "frequencies") and monitor.frequencies
|
|
1303
|
+
else frequencies
|
|
1304
|
+
)
|
|
1305
|
+
if not monitor_frequencies:
|
|
1306
|
+
raise ValueError(
|
|
1307
|
+
f"Monitor '{monitor.name}' has no frequencies and none were passed to to_tidy3d()."
|
|
1308
|
+
)
|
|
1309
|
+
|
|
1310
|
+
if isinstance(monitor, PointMonitor):
|
|
1311
|
+
return td.FieldMonitor(
|
|
1312
|
+
center=_scale3(monitor.position, s),
|
|
1313
|
+
size=(0.0, 0.0, 0.0),
|
|
1314
|
+
freqs=list(monitor_frequencies),
|
|
1315
|
+
fields=list(monitor.fields),
|
|
1316
|
+
name=monitor.name,
|
|
1317
|
+
)
|
|
1318
|
+
|
|
1319
|
+
if isinstance(monitor, DiffractionMonitor):
|
|
1320
|
+
# Tidy3D decomposes the plane wave into diffraction orders over the whole periodic
|
|
1321
|
+
# cell, so the transverse extent must be td.inf; the maxwell monitor's finite period
|
|
1322
|
+
# is the Bloch cell carried by the simulation boundaries, not the monitor size.
|
|
1323
|
+
axis_idx = _axis_name_to_index(monitor.axis)
|
|
1324
|
+
center = list((lo + hi) / 2.0 * s for lo, hi in domain_bounds)
|
|
1325
|
+
size = [td.inf, td.inf, td.inf]
|
|
1326
|
+
center[axis_idx] = float(monitor.plane_position) * s
|
|
1327
|
+
size[axis_idx] = 0.0
|
|
1328
|
+
return td.DiffractionMonitor(
|
|
1329
|
+
center=tuple(center),
|
|
1330
|
+
size=tuple(size),
|
|
1331
|
+
freqs=list(monitor_frequencies),
|
|
1332
|
+
normal_dir=monitor.normal_direction,
|
|
1333
|
+
name=monitor.name,
|
|
1334
|
+
)
|
|
1335
|
+
|
|
1336
|
+
if isinstance(monitor, PermittivityMonitor):
|
|
1337
|
+
return td.PermittivityMonitor(
|
|
1338
|
+
center=_scale3(monitor.position, s),
|
|
1339
|
+
size=_scale3(monitor.size, s),
|
|
1340
|
+
freqs=list(monitor_frequencies),
|
|
1341
|
+
name=monitor.name,
|
|
1342
|
+
)
|
|
1343
|
+
|
|
1344
|
+
if isinstance(monitor, ModeMonitor):
|
|
1345
|
+
# A modal monitor projects the field onto waveguide eigenmodes, which Tidy3D
|
|
1346
|
+
# models with a dedicated ModeMonitor + ModeSpec, not a raw FieldMonitor. The mode
|
|
1347
|
+
# solver must resolve at least mode_index + 1 modes to expose the requested order.
|
|
1348
|
+
axis_idx = _axis_name_to_index(monitor.normal_axis)
|
|
1349
|
+
center = list(_scale3(monitor.position, s))
|
|
1350
|
+
size = list(_scale3(monitor.size, s))
|
|
1351
|
+
size[axis_idx] = 0.0
|
|
1352
|
+
return td.ModeMonitor(
|
|
1353
|
+
center=tuple(center),
|
|
1354
|
+
size=tuple(size),
|
|
1355
|
+
freqs=list(monitor_frequencies),
|
|
1356
|
+
mode_spec=td.ModeSpec(
|
|
1357
|
+
num_modes=_mode_candidate_count(monitor.mode_index),
|
|
1358
|
+
sort_spec=_mode_sort_spec(td, monitor.normal_axis, monitor.polarization),
|
|
1359
|
+
),
|
|
1360
|
+
name=monitor.name,
|
|
1361
|
+
)
|
|
1362
|
+
|
|
1363
|
+
if isinstance(monitor, FinitePlaneMonitor):
|
|
1364
|
+
center = _scale3(monitor.position, s)
|
|
1365
|
+
size = _scale3(monitor.size, s)
|
|
1366
|
+
|
|
1367
|
+
if monitor.compute_flux:
|
|
1368
|
+
return td.FluxMonitor(
|
|
1369
|
+
center=center,
|
|
1370
|
+
size=size,
|
|
1371
|
+
freqs=list(monitor_frequencies),
|
|
1372
|
+
name=monitor.name,
|
|
1373
|
+
normal_dir=monitor.normal_direction,
|
|
1374
|
+
)
|
|
1375
|
+
|
|
1376
|
+
return td.FieldMonitor(
|
|
1377
|
+
center=center,
|
|
1378
|
+
size=size,
|
|
1379
|
+
freqs=list(monitor_frequencies),
|
|
1380
|
+
fields=list(monitor.fields),
|
|
1381
|
+
name=monitor.name,
|
|
1382
|
+
)
|
|
1383
|
+
|
|
1384
|
+
if isinstance(monitor, PlaneMonitor):
|
|
1385
|
+
axis_idx = _axis_name_to_index(monitor.axis)
|
|
1386
|
+
center = list((lo + hi) / 2.0 * s for lo, hi in domain_bounds)
|
|
1387
|
+
size = [(hi - lo) * s for lo, hi in domain_bounds]
|
|
1388
|
+
center[axis_idx] = monitor.position * s
|
|
1389
|
+
size[axis_idx] = 0.0
|
|
1390
|
+
|
|
1391
|
+
if monitor.compute_flux:
|
|
1392
|
+
return td.FluxMonitor(
|
|
1393
|
+
center=tuple(center),
|
|
1394
|
+
size=tuple(size),
|
|
1395
|
+
freqs=list(monitor_frequencies),
|
|
1396
|
+
name=monitor.name,
|
|
1397
|
+
normal_dir=monitor.normal_direction,
|
|
1398
|
+
)
|
|
1399
|
+
|
|
1400
|
+
return td.FieldMonitor(
|
|
1401
|
+
center=tuple(center),
|
|
1402
|
+
size=tuple(size),
|
|
1403
|
+
freqs=list(monitor_frequencies),
|
|
1404
|
+
fields=list(monitor.fields),
|
|
1405
|
+
name=monitor.name,
|
|
1406
|
+
)
|
|
1407
|
+
|
|
1408
|
+
raise NotImplementedError(
|
|
1409
|
+
f"Monitor type '{type(monitor).__name__}' has no Tidy3D monitor mapping."
|
|
1410
|
+
)
|
|
1411
|
+
|
|
1412
|
+
|
|
1413
|
+
# ---------------------------------------------------------------------------
|
|
1414
|
+
# Boundary conversion (no length scaling)
|
|
1415
|
+
# ---------------------------------------------------------------------------
|
|
1416
|
+
|
|
1417
|
+
def _convert_boundary(boundary, td, domain_bounds=None):
|
|
1418
|
+
"""Convert maxwell BoundarySpec to Tidy3D BoundarySpec."""
|
|
1419
|
+
kind = boundary.kind
|
|
1420
|
+
if boundary.bloch_wavevector == "auto":
|
|
1421
|
+
raise ValueError(
|
|
1422
|
+
"Automatic Bloch wavevectors require Simulation.prepare() and cannot be exported to Tidy3D unresolved."
|
|
1423
|
+
)
|
|
1424
|
+
|
|
1425
|
+
def normalized_bloch_component(axis: str) -> float:
|
|
1426
|
+
if domain_bounds is None:
|
|
1427
|
+
raise ValueError("Bloch boundary export requires the physical domain bounds.")
|
|
1428
|
+
axis_index = {"x": 0, "y": 1, "z": 2}[axis]
|
|
1429
|
+
lower, upper = domain_bounds[axis_index]
|
|
1430
|
+
period = float(upper) - float(lower)
|
|
1431
|
+
return float(boundary.bloch_wavevector[axis_index]) * period / (2.0 * np.pi)
|
|
1432
|
+
|
|
1433
|
+
if kind == "pml":
|
|
1434
|
+
pml = td.PML(num_layers=boundary.num_layers)
|
|
1435
|
+
return td.BoundarySpec.all_sides(boundary=pml)
|
|
1436
|
+
|
|
1437
|
+
if kind == "periodic":
|
|
1438
|
+
periodic = td.Periodic()
|
|
1439
|
+
return td.BoundarySpec.all_sides(boundary=periodic)
|
|
1440
|
+
|
|
1441
|
+
if kind == "pec":
|
|
1442
|
+
pec = td.PECBoundary()
|
|
1443
|
+
return td.BoundarySpec.all_sides(boundary=pec)
|
|
1444
|
+
|
|
1445
|
+
if kind == "pmc":
|
|
1446
|
+
pmc = td.PMCBoundary()
|
|
1447
|
+
return td.BoundarySpec.all_sides(boundary=pmc)
|
|
1448
|
+
|
|
1449
|
+
if kind == "bloch":
|
|
1450
|
+
bloch_x = td.BlochBoundary(bloch_vec=normalized_bloch_component("x"))
|
|
1451
|
+
bloch_y = td.BlochBoundary(bloch_vec=normalized_bloch_component("y"))
|
|
1452
|
+
bloch_z = td.BlochBoundary(bloch_vec=normalized_bloch_component("z"))
|
|
1453
|
+
return td.BoundarySpec(
|
|
1454
|
+
x=td.Boundary(plus=bloch_x, minus=bloch_x),
|
|
1455
|
+
y=td.Boundary(plus=bloch_y, minus=bloch_y),
|
|
1456
|
+
z=td.Boundary(plus=bloch_z, minus=bloch_z),
|
|
1457
|
+
)
|
|
1458
|
+
|
|
1459
|
+
if kind == "none":
|
|
1460
|
+
pec = td.PECBoundary()
|
|
1461
|
+
return td.BoundarySpec.all_sides(boundary=pec)
|
|
1462
|
+
|
|
1463
|
+
if kind == "mixed":
|
|
1464
|
+
def convert_face(face_kind, axis):
|
|
1465
|
+
if face_kind == "pml":
|
|
1466
|
+
return td.PML(num_layers=boundary.num_layers)
|
|
1467
|
+
if face_kind == "periodic":
|
|
1468
|
+
return td.Periodic()
|
|
1469
|
+
if face_kind == "pec":
|
|
1470
|
+
return td.PECBoundary()
|
|
1471
|
+
if face_kind == "pmc":
|
|
1472
|
+
return td.PMCBoundary()
|
|
1473
|
+
if face_kind == "bloch":
|
|
1474
|
+
return td.BlochBoundary(bloch_vec=normalized_bloch_component(axis))
|
|
1475
|
+
if face_kind == "none":
|
|
1476
|
+
return td.PECBoundary()
|
|
1477
|
+
raise ValueError(f"Unsupported boundary kind: {face_kind}")
|
|
1478
|
+
|
|
1479
|
+
return td.BoundarySpec(
|
|
1480
|
+
x=td.Boundary(
|
|
1481
|
+
minus=convert_face(boundary.face_kind("x", "low"), "x"),
|
|
1482
|
+
plus=convert_face(boundary.face_kind("x", "high"), "x"),
|
|
1483
|
+
),
|
|
1484
|
+
y=td.Boundary(
|
|
1485
|
+
minus=convert_face(boundary.face_kind("y", "low"), "y"),
|
|
1486
|
+
plus=convert_face(boundary.face_kind("y", "high"), "y"),
|
|
1487
|
+
),
|
|
1488
|
+
z=td.Boundary(
|
|
1489
|
+
minus=convert_face(boundary.face_kind("z", "low"), "z"),
|
|
1490
|
+
plus=convert_face(boundary.face_kind("z", "high"), "z"),
|
|
1491
|
+
),
|
|
1492
|
+
)
|
|
1493
|
+
|
|
1494
|
+
raise ValueError(f"Unsupported boundary kind: {kind}")
|
|
1495
|
+
|
|
1496
|
+
|
|
1497
|
+
# ---------------------------------------------------------------------------
|
|
1498
|
+
# Grid conversion
|
|
1499
|
+
# ---------------------------------------------------------------------------
|
|
1500
|
+
|
|
1501
|
+
def _axis_grid_1d(coords, spacing, td, s):
|
|
1502
|
+
"""One Tidy3D 1D grid for a single axis (lengths x *s*).
|
|
1503
|
+
|
|
1504
|
+
A nonuniform axis carries explicit node coordinates and maps to
|
|
1505
|
+
``td.CustomGridBoundaries`` (grid *boundary* coordinates in Tidy3D units); a
|
|
1506
|
+
uniform axis maps to ``td.UniformGrid`` at the scaled step. maxwell's custom
|
|
1507
|
+
node arrays already span the domain exactly (validated at prepare time), so the
|
|
1508
|
+
scaled coordinates coincide with the exported simulation bounds and Tidy3D
|
|
1509
|
+
discretizes the interior on the identical Yee boundaries.
|
|
1510
|
+
"""
|
|
1511
|
+
if coords is not None:
|
|
1512
|
+
return td.CustomGridBoundaries(coords=np.asarray(coords, dtype=np.float64) * s)
|
|
1513
|
+
return td.UniformGrid(dl=float(spacing) * s)
|
|
1514
|
+
|
|
1515
|
+
|
|
1516
|
+
def _convert_grid(scene, td, s):
|
|
1517
|
+
"""Convert a maxwell ``GridSpec`` to a Tidy3D ``GridSpec`` (lengths x *s*).
|
|
1518
|
+
|
|
1519
|
+
Uniform and per-axis-anisotropic grids export as ``td.UniformGrid`` steps. A
|
|
1520
|
+
nonuniform grid exports as per-axis ``td.CustomGridBoundaries`` carrying the exact
|
|
1521
|
+
Yee node coordinates, so the Tidy3D simulation discretizes on the *same* grid
|
|
1522
|
+
maxwell uses and the two solvers compare cell-for-cell instead of across two
|
|
1523
|
+
independent meshers:
|
|
1524
|
+
|
|
1525
|
+
* ``GridSpec.custom`` forwards its node arrays directly.
|
|
1526
|
+
* ``GridSpec.auto`` is first resolved through maxwell's own mesher
|
|
1527
|
+
(``resolve_auto_grid``), which honours the index-aware step targets,
|
|
1528
|
+
``override_structures`` and ``layer_refinement`` that a lossy parameter map to
|
|
1529
|
+
Tidy3D's ``AutoGrid`` would drop. Exporting the resolved nodes reproduces the
|
|
1530
|
+
maxwell mesh exactly rather than letting Tidy3D's AutoGrid pick a different one.
|
|
1531
|
+
"""
|
|
1532
|
+
grid = scene.grid
|
|
1533
|
+
|
|
1534
|
+
if grid.is_auto:
|
|
1535
|
+
from ..fdtd.meshing import resolve_auto_grid
|
|
1536
|
+
|
|
1537
|
+
x_nodes, y_nodes, z_nodes = resolve_auto_grid(scene)
|
|
1538
|
+
return td.GridSpec(
|
|
1539
|
+
grid_x=_axis_grid_1d(x_nodes, None, td, s),
|
|
1540
|
+
grid_y=_axis_grid_1d(y_nodes, None, td, s),
|
|
1541
|
+
grid_z=_axis_grid_1d(z_nodes, None, td, s),
|
|
1542
|
+
)
|
|
1543
|
+
|
|
1544
|
+
if grid.is_custom:
|
|
1545
|
+
return td.GridSpec(
|
|
1546
|
+
grid_x=_axis_grid_1d(grid.x_coords, grid.dx, td, s),
|
|
1547
|
+
grid_y=_axis_grid_1d(grid.y_coords, grid.dy, td, s),
|
|
1548
|
+
grid_z=_axis_grid_1d(grid.z_coords, grid.dz, td, s),
|
|
1549
|
+
)
|
|
1550
|
+
|
|
1551
|
+
if grid.dx == grid.dy == grid.dz:
|
|
1552
|
+
return td.GridSpec.uniform(dl=grid.dx * s)
|
|
1553
|
+
return td.GridSpec(
|
|
1554
|
+
grid_x=td.UniformGrid(dl=grid.dx * s),
|
|
1555
|
+
grid_y=td.UniformGrid(dl=grid.dy * s),
|
|
1556
|
+
grid_z=td.UniformGrid(dl=grid.dz * s),
|
|
1557
|
+
)
|
|
1558
|
+
|
|
1559
|
+
|
|
1560
|
+
# ---------------------------------------------------------------------------
|
|
1561
|
+
# Main conversion
|
|
1562
|
+
# ---------------------------------------------------------------------------
|
|
1563
|
+
|
|
1564
|
+
def scene_to_tidy3d(
|
|
1565
|
+
scene,
|
|
1566
|
+
*,
|
|
1567
|
+
frequencies: float | Sequence[float] | None = None,
|
|
1568
|
+
run_time: float | None = None,
|
|
1569
|
+
length_scale: float = _M_TO_UM,
|
|
1570
|
+
**kwargs,
|
|
1571
|
+
) -> tidy3d.Simulation:
|
|
1572
|
+
"""Convert a maxwell ``Scene`` to a ``tidy3d.Simulation``.
|
|
1573
|
+
|
|
1574
|
+
Parameters
|
|
1575
|
+
----------
|
|
1576
|
+
scene : Scene
|
|
1577
|
+
The maxwell scene to convert.
|
|
1578
|
+
frequencies : float or sequence of float, optional
|
|
1579
|
+
Monitoring / source frequencies in Hz. Required for monitors.
|
|
1580
|
+
run_time : float, optional
|
|
1581
|
+
Simulation run time in seconds. If not given, an estimate is made
|
|
1582
|
+
from the domain size (10 light-crossing times).
|
|
1583
|
+
length_scale : float
|
|
1584
|
+
Factor to convert maxwell lengths (metres) to Tidy3D lengths.
|
|
1585
|
+
Default ``1e6`` (metres tomicrometres, the Tidy3D convention).
|
|
1586
|
+
**kwargs
|
|
1587
|
+
Extra keyword arguments forwarded to ``tidy3d.Simulation``.
|
|
1588
|
+
|
|
1589
|
+
Returns
|
|
1590
|
+
-------
|
|
1591
|
+
tidy3d.Simulation
|
|
1592
|
+
"""
|
|
1593
|
+
td = _ensure_tidy3d()
|
|
1594
|
+
s = length_scale # shorthand
|
|
1595
|
+
|
|
1596
|
+
# -- frequencies -----------------------------------------------------------
|
|
1597
|
+
if isinstance(frequencies, (int, float)):
|
|
1598
|
+
frequencies = (float(frequencies),)
|
|
1599
|
+
elif frequencies is not None:
|
|
1600
|
+
frequencies = tuple(float(frequency) for frequency in frequencies)
|
|
1601
|
+
|
|
1602
|
+
# -- domain ----------------------------------------------------------------
|
|
1603
|
+
if scene.domain is None:
|
|
1604
|
+
raise ValueError("Scene must have a Domain for Tidy3D export.")
|
|
1605
|
+
center, size = _domain_to_center_size(scene.domain, s)
|
|
1606
|
+
|
|
1607
|
+
# -- run_time estimate -----------------------------------------------------
|
|
1608
|
+
if run_time is None:
|
|
1609
|
+
c0 = 299_792_458.0
|
|
1610
|
+
max_extent_m = max(
|
|
1611
|
+
b[1] - b[0] for b in scene.domain.bounds
|
|
1612
|
+
)
|
|
1613
|
+
run_time = 10.0 * max_extent_m / c0
|
|
1614
|
+
|
|
1615
|
+
# -- grid ------------------------------------------------------------------
|
|
1616
|
+
td_grid = None
|
|
1617
|
+
if scene.grid is not None:
|
|
1618
|
+
td_grid = _convert_grid(scene, td, s)
|
|
1619
|
+
|
|
1620
|
+
# -- boundary --------------------------------------------------------------
|
|
1621
|
+
td_boundary = td.BoundarySpec.all_sides(boundary=td.PML())
|
|
1622
|
+
if scene.boundary is not None:
|
|
1623
|
+
td_boundary = _convert_boundary(scene.boundary, td, scene.domain.bounds)
|
|
1624
|
+
|
|
1625
|
+
# -- structures ------------------------------------------------------------
|
|
1626
|
+
td_structures = []
|
|
1627
|
+
for structure in (scene.structures or []):
|
|
1628
|
+
td_structures.append(_convert_structure(structure, td, s, frequencies=frequencies))
|
|
1629
|
+
|
|
1630
|
+
# -- sources ---------------------------------------------------------------
|
|
1631
|
+
domain_bounds = scene.domain.bounds
|
|
1632
|
+
td_sources = []
|
|
1633
|
+
sources = scene.resolved_sources() if hasattr(scene, "resolved_sources") else (scene.sources or [])
|
|
1634
|
+
for source in sources:
|
|
1635
|
+
td_sources.append(_convert_source(source, scene, td, s, frequencies=frequencies))
|
|
1636
|
+
|
|
1637
|
+
# -- monitors --------------------------------------------------------------
|
|
1638
|
+
td_monitors = []
|
|
1639
|
+
monitors = scene.resolved_monitors() if hasattr(scene, "resolved_monitors") else (scene.monitors or [])
|
|
1640
|
+
for monitor in monitors:
|
|
1641
|
+
td_monitors.append(_convert_monitor(monitor, domain_bounds, frequencies, td, s))
|
|
1642
|
+
|
|
1643
|
+
# -- symmetry --------------------------------------------------------------
|
|
1644
|
+
td_symmetry = (0, 0, 0)
|
|
1645
|
+
if scene.symmetry is not None:
|
|
1646
|
+
# Tidy3D encodes symmetry about the domain center only; the folded face
|
|
1647
|
+
# (low/high) has no Tidy3D counterpart and is dropped in the export.
|
|
1648
|
+
sym_map = {"PEC": -1, "PMC": 1}
|
|
1649
|
+
td_symmetry = tuple(
|
|
1650
|
+
0 if entry is None else sym_map.get(entry[0], 0)
|
|
1651
|
+
for entry in scene.symmetry
|
|
1652
|
+
)
|
|
1653
|
+
|
|
1654
|
+
# -- build simulation ------------------------------------------------------
|
|
1655
|
+
sim_kwargs = dict(
|
|
1656
|
+
center=center,
|
|
1657
|
+
size=size,
|
|
1658
|
+
run_time=run_time,
|
|
1659
|
+
structures=td_structures,
|
|
1660
|
+
sources=td_sources,
|
|
1661
|
+
monitors=td_monitors,
|
|
1662
|
+
boundary_spec=td_boundary,
|
|
1663
|
+
symmetry=td_symmetry,
|
|
1664
|
+
)
|
|
1665
|
+
if td_grid is not None:
|
|
1666
|
+
sim_kwargs["grid_spec"] = td_grid
|
|
1667
|
+
sim_kwargs.update(kwargs)
|
|
1668
|
+
|
|
1669
|
+
return td.Simulation(**sim_kwargs)
|