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.
Files changed (107) hide show
  1. witwin/maxwell/__init__.py +164 -0
  2. witwin/maxwell/adapters/__init__.py +1 -0
  3. witwin/maxwell/adapters/gds.py +203 -0
  4. witwin/maxwell/adapters/tidy3d.py +1669 -0
  5. witwin/maxwell/adjoint_inputs.py +70 -0
  6. witwin/maxwell/compiler/__init__.py +11 -0
  7. witwin/maxwell/compiler/materials.py +1355 -0
  8. witwin/maxwell/compiler/monitors.py +164 -0
  9. witwin/maxwell/compiler/sources.py +222 -0
  10. witwin/maxwell/fdfd/__init__.py +3 -0
  11. witwin/maxwell/fdfd/adjoint/__init__.py +3 -0
  12. witwin/maxwell/fdfd/adjoint/bridge.py +180 -0
  13. witwin/maxwell/fdfd/ams.py +300 -0
  14. witwin/maxwell/fdfd/krylov.py +280 -0
  15. witwin/maxwell/fdfd/plotting.py +110 -0
  16. witwin/maxwell/fdfd/postprocess.py +33 -0
  17. witwin/maxwell/fdfd/solver.py +1057 -0
  18. witwin/maxwell/fdtd/__init__.py +4 -0
  19. witwin/maxwell/fdtd/adjoint/__init__.py +21 -0
  20. witwin/maxwell/fdtd/adjoint/bridge.py +746 -0
  21. witwin/maxwell/fdtd/adjoint/capabilities.py +29 -0
  22. witwin/maxwell/fdtd/adjoint/core.py +3614 -0
  23. witwin/maxwell/fdtd/adjoint/dispatch.py +684 -0
  24. witwin/maxwell/fdtd/adjoint/native.py +2561 -0
  25. witwin/maxwell/fdtd/adjoint/profiler.py +195 -0
  26. witwin/maxwell/fdtd/adjoint/reverse_common.py +207 -0
  27. witwin/maxwell/fdtd/adjoint/seeds.py +524 -0
  28. witwin/maxwell/fdtd/boundary/__init__.py +37 -0
  29. witwin/maxwell/fdtd/boundary/bloch.py +90 -0
  30. witwin/maxwell/fdtd/boundary/common.py +41 -0
  31. witwin/maxwell/fdtd/boundary/cpml.py +527 -0
  32. witwin/maxwell/fdtd/boundary/runtime.py +209 -0
  33. witwin/maxwell/fdtd/checkpoint.py +227 -0
  34. witwin/maxwell/fdtd/coords.py +32 -0
  35. witwin/maxwell/fdtd/cuda/__init__.py +11 -0
  36. witwin/maxwell/fdtd/cuda/backend.py +3532 -0
  37. witwin/maxwell/fdtd/cuda/build.py +292 -0
  38. witwin/maxwell/fdtd/cuda/extension.cpp +2053 -0
  39. witwin/maxwell/fdtd/cuda/kernels/adjoint.cu +5290 -0
  40. witwin/maxwell/fdtd/cuda/kernels/aniso.cu +1704 -0
  41. witwin/maxwell/fdtd/cuda/kernels/boundary.cu +424 -0
  42. witwin/maxwell/fdtd/cuda/kernels/common.cu +74 -0
  43. witwin/maxwell/fdtd/cuda/kernels/common.cuh +82 -0
  44. witwin/maxwell/fdtd/cuda/kernels/dispersive.cu +782 -0
  45. witwin/maxwell/fdtd/cuda/kernels/electric.cu +3613 -0
  46. witwin/maxwell/fdtd/cuda/kernels/magnetic.cu +781 -0
  47. witwin/maxwell/fdtd/cuda/kernels/observers.cu +347 -0
  48. witwin/maxwell/fdtd/cuda/kernels/projection.cu +211 -0
  49. witwin/maxwell/fdtd/cuda/kernels/sources.cu +1482 -0
  50. witwin/maxwell/fdtd/cuda/kernels/spectral.cu +297 -0
  51. witwin/maxwell/fdtd/cuda/launch.h +23 -0
  52. witwin/maxwell/fdtd/cuda/prebuilt/witwin_maxwell_fdtd_cuda.pyd +0 -0
  53. witwin/maxwell/fdtd/cuda/runtime/__init__.py +6 -0
  54. witwin/maxwell/fdtd/cuda/runtime/dispatcher.py +6 -0
  55. witwin/maxwell/fdtd/cuda/runtime/graph.py +33 -0
  56. witwin/maxwell/fdtd/cuda/tensors.h +35 -0
  57. witwin/maxwell/fdtd/dispersion.py +48 -0
  58. witwin/maxwell/fdtd/evaluation.py +290 -0
  59. witwin/maxwell/fdtd/excitation/__init__.py +27 -0
  60. witwin/maxwell/fdtd/excitation/injection.py +1102 -0
  61. witwin/maxwell/fdtd/excitation/modes.py +1675 -0
  62. witwin/maxwell/fdtd/excitation/spatial.py +469 -0
  63. witwin/maxwell/fdtd/excitation/temporal.py +510 -0
  64. witwin/maxwell/fdtd/excitation/tfsf_apply.py +264 -0
  65. witwin/maxwell/fdtd/excitation/tfsf_common.py +421 -0
  66. witwin/maxwell/fdtd/excitation/tfsf_specs.py +490 -0
  67. witwin/maxwell/fdtd/excitation/tfsf_state.py +778 -0
  68. witwin/maxwell/fdtd/io.py +31 -0
  69. witwin/maxwell/fdtd/material_pullback.py +371 -0
  70. witwin/maxwell/fdtd/meshing/__init__.py +3 -0
  71. witwin/maxwell/fdtd/meshing/autogrid.py +335 -0
  72. witwin/maxwell/fdtd/observers.py +1453 -0
  73. witwin/maxwell/fdtd/plotting.py +121 -0
  74. witwin/maxwell/fdtd/postprocess.py +205 -0
  75. witwin/maxwell/fdtd/runtime/__init__.py +119 -0
  76. witwin/maxwell/fdtd/runtime/initialization.py +236 -0
  77. witwin/maxwell/fdtd/runtime/materials.py +1563 -0
  78. witwin/maxwell/fdtd/runtime/module_cache.py +31 -0
  79. witwin/maxwell/fdtd/runtime/spectral.py +581 -0
  80. witwin/maxwell/fdtd/runtime/stepping.py +1987 -0
  81. witwin/maxwell/fdtd/solver.py +502 -0
  82. witwin/maxwell/geometry/__init__.py +5 -0
  83. witwin/maxwell/geometry/polyslab.py +299 -0
  84. witwin/maxwell/materials.py +3 -0
  85. witwin/maxwell/media.py +1467 -0
  86. witwin/maxwell/monitors.py +743 -0
  87. witwin/maxwell/ports.py +113 -0
  88. witwin/maxwell/postprocess/RCS.py +139 -0
  89. witwin/maxwell/postprocess/__init__.py +42 -0
  90. witwin/maxwell/postprocess/diffraction.py +219 -0
  91. witwin/maxwell/postprocess/directivity.py +152 -0
  92. witwin/maxwell/postprocess/emission.py +67 -0
  93. witwin/maxwell/postprocess/modal.py +324 -0
  94. witwin/maxwell/postprocess/nfft.py +266 -0
  95. witwin/maxwell/postprocess/scattering_parameters.py +358 -0
  96. witwin/maxwell/postprocess/stratton_chu.py +1193 -0
  97. witwin/maxwell/result.py +967 -0
  98. witwin/maxwell/scene.py +1338 -0
  99. witwin/maxwell/simulation.py +659 -0
  100. witwin/maxwell/sources.py +917 -0
  101. witwin/maxwell/visualization/__init__.py +28 -0
  102. witwin/maxwell/visualization/interactive.py +484 -0
  103. witwin/maxwell/visualization/plots.py +568 -0
  104. witwin_maxwell-0.3.0.dist-info/METADATA +184 -0
  105. witwin_maxwell-0.3.0.dist-info/RECORD +107 -0
  106. witwin_maxwell-0.3.0.dist-info/WHEEL +4 -0
  107. 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)