Skip to content

Commit 6b0c432

Browse files
committed
feat[FieldData]: export to and import from ZBF
1 parent cfff229 commit 6b0c432

File tree

7 files changed

+620
-7
lines changed

7 files changed

+620
-7
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2828
- Differentiable function `td.plugins.autograd.interpolate_spline` for 1D linear, quadratic, and cubic spline interpolation, supporting differentiation with respect to the interpolated values (`y_points`) and optional endpoint derivative constraints.
2929
- `SteadyEnergyBandMonitor` in the Charge solver.
3030
- Pretty printing enabled with `rich.print` for the material library, materials, and their variants. In notebooks, this can be accessed using `rich.print` or `display`, or by evaluating the material library, a material, or a variant in a cell.
31+
- `FieldData` and `ModeData` support exporting E fields to a Zemax Beam File (ZBF) with `.to_zbf()` (warning: experimental feature).
32+
- `FieldDataset` supports reading E fields from a Zemax Beam File (ZBF) with `.from_zbf()` (warning: experimental feature).
3133

3234
### Changed
3335
- Performance enhancement for adjoint gradient calculations by optimizing field interpolation.

tests/test_data/test_monitor_data.py

Lines changed: 190 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,11 @@
2121
ModeData,
2222
PermittivityData,
2323
)
24+
from tidy3d.components.data.zbf import ZBFData
25+
from tidy3d.constants import UnitScaling
2426
from tidy3d.exceptions import DataError
2527

26-
from ..utils import AssertLogLevel
28+
from ..utils import AssertLogLevel, run_emulated
2729
from .test_data_arrays import (
2830
AUX_FIELD_TIME_MONITOR,
2931
DIFFRACTION_MONITOR,
@@ -865,3 +867,190 @@ def test_no_nans():
865867
)
866868
with pytest.raises(pydantic.ValidationError):
867869
td.CustomMedium(eps_dataset=eps_dataset_nan)
870+
871+
872+
class TestZBF:
873+
"""Tests exporting field data to a zbf file"""
874+
875+
freq0 = td.C_0 / 0.75
876+
freqs = (freq0, freq0 * 1.01)
877+
878+
def simdata(self, monitor) -> td.SimulationData:
879+
"""Returns emulated simulation data"""
880+
source = td.PointDipole(
881+
center=(-1.5, 0, 0),
882+
source_time=td.GaussianPulse(freq0=self.freq0, fwidth=self.freq0 / 10.0),
883+
polarization="Ey",
884+
)
885+
sim = td.Simulation(
886+
size=(4, 3, 3),
887+
grid_spec=td.GridSpec.auto(min_steps_per_wvl=10),
888+
structures=[],
889+
sources=[source],
890+
monitors=[monitor],
891+
run_time=120 / self.freq0,
892+
)
893+
return run_emulated(sim)
894+
895+
@pytest.fixture(scope="class")
896+
def field_data(self) -> td.FieldData:
897+
"""Make random field data from an emulated simulation run."""
898+
monitor = td.FieldMonitor(
899+
size=(td.inf, td.inf, 0),
900+
freqs=self.freqs,
901+
name="fields",
902+
colocate=True,
903+
)
904+
return self.simdata(monitor)["fields"]
905+
906+
@pytest.fixture(scope="class")
907+
def mode_data(self) -> td.ModeData:
908+
"""Make random ModeData from an emulated simulation run."""
909+
monitor = td.ModeMonitor(
910+
size=(td.inf, td.inf, 0),
911+
freqs=self.freqs,
912+
name="modes",
913+
colocate=True,
914+
mode_spec=td.ModeSpec(num_modes=2, target_neff=4.0),
915+
store_fields_direction="+",
916+
)
917+
return self.simdata(monitor)["modes"]
918+
919+
@pytest.mark.parametrize("background_index", [1, 2, 3])
920+
@pytest.mark.parametrize("freq", list(freqs) + [None])
921+
@pytest.mark.parametrize("n_x", [2**5, 2**6])
922+
@pytest.mark.parametrize("n_y", [2**5, 2**6])
923+
@pytest.mark.parametrize("units", ["mm", "cm", "in", "m"])
924+
def test_fielddata_tozbf_readzbf(
925+
self, tmp_path, field_data, background_index, freq, n_x, n_y, units
926+
):
927+
"""Test that FieldData.to_zbf() -> ZBFData.read_zbf() works"""
928+
zbf_filename = tmp_path / "testzbf.zbf"
929+
930+
# write to zbf and then load it back in
931+
ex, ey = field_data.to_zbf(
932+
fname=zbf_filename,
933+
background_refractive_index=background_index,
934+
freq=freq,
935+
n_x=n_x,
936+
n_y=n_y,
937+
units=units,
938+
)
939+
zbfdata = ZBFData.read_zbf(zbf_filename)
940+
941+
assert zbfdata.background_refractive_index == background_index
942+
943+
unitscaling = UnitScaling[units]
944+
945+
if freq is not None:
946+
assert np.isclose(zbfdata.wavelength / unitscaling, td.C_0 / freq)
947+
else:
948+
assert np.isclose(
949+
zbfdata.wavelength / unitscaling,
950+
td.C_0 / np.mean(field_data.monitor.freqs),
951+
)
952+
953+
assert zbfdata.nx == n_x
954+
assert zbfdata.ny == n_y
955+
956+
# check that fields are close
957+
assert np.allclose(ex.values, zbfdata.Ex)
958+
assert np.allclose(ey.values, zbfdata.Ey)
959+
960+
@pytest.mark.parametrize("mode_index", [0, 1])
961+
def test_tozbf_modedata(self, tmp_path, mode_data, mode_index):
962+
"""Tests ModeData.to_zbf()"""
963+
zbf_filename = tmp_path / "testzbf_modedata.zbf"
964+
965+
# write to zbf and then load it back in
966+
ex, ey = mode_data.to_zbf(
967+
fname=zbf_filename,
968+
background_refractive_index=1,
969+
freq=self.freq0,
970+
mode_index=mode_index,
971+
n_x=32,
972+
n_y=32,
973+
units="mm",
974+
)
975+
zbfdata = ZBFData.read_zbf(zbf_filename)
976+
977+
# check that fields are close
978+
assert np.allclose(ex.values, zbfdata.Ex)
979+
assert np.allclose(ey.values, zbfdata.Ey)
980+
981+
def test_tozbf_modedata_fails(self, tmp_path, mode_data):
982+
"""Asserts that Modedata.to_zbf() fails if mode_index is not specified"""
983+
with pytest.raises(ValueError) as e:
984+
_ = mode_data.to_zbf(
985+
fname=tmp_path / "testzbf_modedata_fail.zbf",
986+
background_refractive_index=1,
987+
freq=self.freq0,
988+
mode_index=None,
989+
n_x=32,
990+
n_y=32,
991+
units="mm",
992+
)
993+
994+
@pytest.mark.parametrize("n_x", [16, 2**14, 33])
995+
@pytest.mark.parametrize("n_y", [16, 2**14, 33])
996+
def test_tozbf_nxny_fails(self, tmp_path, field_data, n_x, n_y):
997+
"""Asserts that to_zbf() fails when n_x and n_y are invalid values."""
998+
with pytest.raises(ValueError) as e:
999+
_ = field_data.to_zbf(
1000+
fname=tmp_path / "testzbf_nxny_fail.zbf",
1001+
background_refractive_index=1,
1002+
freq=self.freq0,
1003+
n_x=n_x,
1004+
n_y=n_y,
1005+
units="mm",
1006+
)
1007+
1008+
@pytest.mark.parametrize("units", ["mmm", "123"])
1009+
def test_tozbf_units_fails(self, tmp_path, field_data, units):
1010+
"""Asserts that to_zbf() fails when units are invalid."""
1011+
with pytest.raises(ValueError) as e:
1012+
_ = field_data.to_zbf(
1013+
fname=tmp_path / "testzbf_nxny_fail.zbf",
1014+
background_refractive_index=1,
1015+
freq=self.freq0,
1016+
n_x=32,
1017+
n_y=32,
1018+
units=units,
1019+
)
1020+
1021+
def test_from_zbf(self, tmp_path, field_data):
1022+
"""Tests creating a field dataset from a zbf"""
1023+
zbf_filename = tmp_path / "testzbf.zbf"
1024+
# write to zbf and then load it back in
1025+
ex, ey = field_data.to_zbf(
1026+
fname=zbf_filename,
1027+
background_refractive_index=1,
1028+
n_x=32,
1029+
n_y=32,
1030+
units="mm",
1031+
)
1032+
1033+
# create a field dataset from the zbf file
1034+
fd = td.FieldDataset.from_zbf(filename=zbf_filename, dim1="x", dim2="y")
1035+
1036+
# compare loaded field data to saved data
1037+
assert np.allclose(ex.values, fd.Ex.values.squeeze())
1038+
assert np.allclose(ey.values, fd.Ey.values.squeeze())
1039+
1040+
@pytest.mark.parametrize(
1041+
"dim1,dim2", [("x", "x"), ("y", "y"), ("z", "z"), ("1", "2"), ("c", "z")]
1042+
)
1043+
def test_from_zbf_dimsfail(self, tmp_path, field_data, dim1, dim2):
1044+
"""Tests fail cases when the dimensions to populate are wrong."""
1045+
zbf_filename = tmp_path / "testzbf.zbf"
1046+
# write to zbf and then load it back in
1047+
_, _ = field_data.to_zbf(
1048+
fname=zbf_filename,
1049+
background_refractive_index=1,
1050+
n_x=32,
1051+
n_y=32,
1052+
units="mm",
1053+
)
1054+
# this should fail
1055+
with pytest.raises(ValueError) as e:
1056+
_ = td.FieldDataset.from_zbf(filename=zbf_filename, dim1=dim1, dim2=dim2)

tidy3d/components/data/dataset.py

Lines changed: 90 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,17 @@
33
from __future__ import annotations
44

55
from abc import ABC, abstractmethod
6-
from typing import Any, Callable, Dict, Optional, Union
6+
from typing import Any, Callable, Dict, Optional, Union, get_args
77

88
import numpy as np
99
import pydantic.v1 as pd
1010
import xarray as xr
1111

12-
from ...constants import PICOSECOND_PER_NANOMETER_PER_KILOMETER
12+
from ...constants import C_0, PICOSECOND_PER_NANOMETER_PER_KILOMETER, UnitScaling
1313
from ...exceptions import DataError
1414
from ...log import log
1515
from ..base import Tidy3dBaseModel
16-
from ..types import Axis
16+
from ..types import Axis, xyz
1717
from .data_array import (
1818
DataArray,
1919
EMEScalarFieldDataArray,
@@ -28,6 +28,7 @@
2828
TimeDataArray,
2929
TriangleMeshDataArray,
3030
)
31+
from .zbf import ZBFData
3132

3233
DEFAULT_MAX_SAMPLES_PER_STEP = 10_000
3334
DEFAULT_MAX_CELLS_PER_STEP = 10_000
@@ -257,6 +258,92 @@ class FieldDataset(ElectromagneticFieldDataset):
257258
description="Spatial distribution of the z-component of the magnetic field.",
258259
)
259260

261+
def from_zbf(filename: str, dim1: xyz, dim2: xyz) -> FieldDataset:
262+
"""Creates a :class:`.FieldDataset` from a Zemax Beam File (``.zbf``).
263+
264+
Parameters
265+
----------
266+
filename: str
267+
The file name of the .zbf file to read.
268+
dim1: xyz
269+
Tangential field component to map the x-dimension of the zbf data to.
270+
eg. ``dim1 = "z"`` sets ``FieldDataset.Ez`` to ``Ex`` of the zbf data.
271+
dim2: xyz
272+
Tangential field component to map the y-dimension of the zbf data to.
273+
eg. ``dim2 = "z"`` sets ``FieldDataset.Ez`` to ``Ey`` of the zbf data.
274+
275+
Returns
276+
-------
277+
:class:`.FieldDataset`
278+
A :class:`.FieldDataset` object with two tangential E field components populated
279+
by zbf data.
280+
281+
See Also
282+
--------
283+
:class:`.ZBFData`:
284+
A class containing data read in from a ``.zbf`` file.
285+
"""
286+
log.warning(
287+
"'FieldDataset.from_zbf()' is currently an experimental feature."
288+
" If any issues are encountered, please contact Flexcompute support 'https://www.flexcompute.com/tidy3d/technical-support/'"
289+
)
290+
291+
if dim1 not in get_args(xyz):
292+
raise ValueError(f"'dim1' = '{dim1}' is not allowed, must be one of 'x', 'y', or 'z'.")
293+
if dim2 not in get_args(xyz):
294+
raise ValueError(f"'dim2' = '{dim2}' is not allowed, must be one of 'x', 'y', or 'z'.")
295+
if dim1 == dim2:
296+
raise ValueError("'dim1' and 'dim2' must be different.")
297+
298+
# get the third dimension
299+
dim3 = list(set(get_args(xyz)) - {dim1, dim2})[0]
300+
dims = {"x": 0, "y": 1, "z": 2}
301+
dim2expand = dims[dim3] # this is for expanding E field arrays
302+
303+
# load zbf data
304+
zbfdata = ZBFData.read_zbf(filename)
305+
306+
# Grab E fields, dimensions, wavelength
307+
edim1 = zbfdata.Ex
308+
edim2 = zbfdata.Ey
309+
n1 = zbfdata.nx
310+
n2 = zbfdata.ny
311+
d1 = zbfdata.dx / UnitScaling[zbfdata.unit]
312+
d2 = zbfdata.dy / UnitScaling[zbfdata.unit]
313+
wavelength = zbfdata.wavelength / UnitScaling[zbfdata.unit]
314+
315+
# make scalar field data arrays
316+
len1 = d1 * (n1 - 1)
317+
len2 = d2 * (n2 - 1)
318+
coords1 = np.linspace(-len1 / 2, len1 / 2, n1)
319+
coords2 = np.linspace(-len2 / 2, len2 / 2, n2)
320+
f = [C_0 / wavelength]
321+
Edim1 = ScalarFieldDataArray(
322+
np.expand_dims(edim1, axis=(dim2expand, 3)),
323+
coords={
324+
dim1: coords1,
325+
dim2: coords2,
326+
dim3: [0],
327+
"f": f,
328+
},
329+
)
330+
Edim2 = ScalarFieldDataArray(
331+
np.expand_dims(edim2, axis=(dim2expand, 3)),
332+
coords={
333+
dim1: coords1,
334+
dim2: coords2,
335+
dim3: [0],
336+
"f": f,
337+
},
338+
)
339+
340+
return FieldDataset(
341+
**{
342+
f"E{dim1}": Edim1,
343+
f"E{dim2}": Edim2,
344+
}
345+
)
346+
260347

261348
class FieldTimeDataset(ElectromagneticFieldDataset):
262349
"""Dataset storing a collection of the scalar components of E and H fields in the time domain

0 commit comments

Comments
 (0)