Skip to content

Commit e51c379

Browse files
marcorudolphflexyaugenst-flex
authored andcommitted
add lazy option for loading simulation data
1 parent 487b61d commit e51c379

File tree

6 files changed

+220
-47
lines changed

6 files changed

+220
-47
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1212
- New `MediumMonitor` that returns both permittivity and permeability profiles.
1313
- Task names are now optional when using `run(sim)` or `Job`. When running multiple jobs (via `run_async` or `Batch`), you can also provide simulations as a list without specifying task names. The previous dictionary-based format with explicit task names is still supported.
1414
- Added support for `tidy3d-extras`, an optional plugin that enables more accurate local mode solving via subpixel averaging.
15+
- Enabled lazy loading of data via `web.load(..., lazy=True)`. When used, this returns a lightweight proxy object holding a reference to the data. On first access to any field or method, the proxy transparently loads the full object (same as with the default lazy=False).
1516

1617
### Changed
1718
- `LayerRefinementSpec` defaults to assuming structures made of different materials are interior-disjoint for more efficient mesh generation.

tests/test_web/test_tidy3d_stub.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import responses
77

88
import tidy3d as td
9+
from tests.utils import AssertLogLevel
910
from tidy3d.components.data.data_array import ScalarFieldDataArray
1011
from tidy3d.components.data.monitor_data import FieldData
1112
from tidy3d.components.data.sim_data import SimulationData
@@ -122,6 +123,45 @@ def test_stub_data_postprocess_logs(tmp_path):
122123
Tidy3dStubData.postprocess(file_path)
123124

124125

126+
@responses.activate
127+
def test_stub_data_lazy_loading(tmp_path):
128+
"""Tests the postprocess method with lazy loading of Tidy3dStubData when simulation diverged."""
129+
td.log.set_capture(True)
130+
sim_diverged_log = "The simulation has diverged!"
131+
132+
# make sim data where test diverged
133+
sim_data = make_sim_data()
134+
sim_data = sim_data.updated_copy(diverged=True, log=sim_diverged_log)
135+
file_path = os.path.join(tmp_path, "test_diverged.hdf5")
136+
sim_data.to_file(file_path)
137+
138+
# default case with lazy=False should output a warning
139+
with AssertLogLevel("WARNING", contains_str=sim_diverged_log):
140+
Tidy3dStubData.postprocess(file_path, lazy=False)
141+
142+
# we expect no warning in lazy mode as object should not be loaded
143+
with AssertLogLevel(None):
144+
sim_data = Tidy3dStubData.postprocess(file_path, lazy=True)
145+
146+
sim_data_copy = sim_data.copy()
147+
assert type(sim_data).__name__ == "SimulationDataProxy"
148+
assert type(sim_data_copy).__name__ == "SimulationDataProxy"
149+
150+
# the type should be still SimulationData despite being lazy
151+
assert isinstance(sim_data, SimulationData)
152+
153+
# variable dict should only contain metadata to load the data, not the data itself
154+
assert set(sim_data.__dict__.keys()) == {
155+
"_lazy_fname",
156+
"_lazy_group_path",
157+
"_lazy_parse_obj_kwargs",
158+
}
159+
160+
# we expect a warning from the lazy object if some field is accessed
161+
with AssertLogLevel("WARNING", contains_str=sim_diverged_log):
162+
_ = sim_data.monitor_data
163+
164+
125165
def test_default_task_name():
126166
sim = make_sim()
127167
stub = Tidy3dStub(simulation=sim)

tidy3d/components/base.py

Lines changed: 96 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -308,17 +308,30 @@ def help(self, methods: bool = False) -> None:
308308

309309
@classmethod
310310
def from_file(
311-
cls, fname: str, group_path: Optional[str] = None, **parse_obj_kwargs
311+
cls,
312+
fname: str,
313+
group_path: Optional[str] = None,
314+
lazy: bool = False,
315+
on_load: Optional[Callable] = None,
316+
**parse_obj_kwargs,
312317
) -> Tidy3dBaseModel:
313318
"""Loads a :class:`Tidy3dBaseModel` from .yaml, .json, .hdf5, or .hdf5.gz file.
314319
315320
Parameters
316321
----------
317322
fname : str
318323
Full path to the file to load the :class:`Tidy3dBaseModel` from.
319-
group_path : str, optional
324+
group_path : str | None = None
320325
Path to a group inside the file to use as the base level. Only for hdf5 files.
321326
Starting `/` is optional.
327+
lazy : bool = False
328+
Whether to load the actual data (``lazy=False``) or return a proxy that loads
329+
the data when accessed (``lazy=True``).
330+
on_load : Callable | None = None
331+
Callback function executed once the model is fully materialized.
332+
Only used if ``lazy=True``. The callback is invoked with the loaded
333+
instance as its sole argument, enabling post-processing such as
334+
validation, logging, or warnings checks.
322335
**parse_obj_kwargs
323336
Keyword arguments passed to either pydantic's ``parse_obj`` function when loading model.
324337
@@ -331,8 +344,14 @@ def from_file(
331344
-------
332345
>>> simulation = Simulation.from_file(fname='folder/sim.json') # doctest: +SKIP
333346
"""
347+
if lazy:
348+
Proxy = _make_lazy_proxy(cls, on_load=on_load) # staticmethod usage
349+
return Proxy(fname, group_path, parse_obj_kwargs)
334350
model_dict = cls.dict_from_file(fname=fname, group_path=group_path)
335-
return cls.parse_obj(model_dict, **parse_obj_kwargs)
351+
obj = cls.parse_obj(model_dict, **parse_obj_kwargs)
352+
if not lazy and on_load is not None:
353+
on_load(obj)
354+
return obj
336355

337356
@classmethod
338357
def dict_from_file(cls, fname: str, group_path: Optional[str] = None) -> dict:
@@ -1201,3 +1220,77 @@ def to_sci(value: float, exponent: int, precision: int) -> str:
12011220
sci_max = to_sci(max_val, common_exponent, precision)
12021221

12031222
return sci_min, sci_max
1223+
1224+
1225+
def _make_lazy_proxy(
1226+
target_cls: type,
1227+
on_load: Optional[Callable[[Any], None]] = None,
1228+
) -> type:
1229+
"""
1230+
Return a lazy-loading proxy subclass of ``target_cls``.
1231+
1232+
Parameters
1233+
----------
1234+
target_cls : type
1235+
Must implement ``dict_from_file`` and ``parse_obj``.
1236+
on_load : Callable[[Any], None] | None = None
1237+
A function to call with the fully loaded instance once loaded.
1238+
1239+
Returns
1240+
-------
1241+
type
1242+
A class named ``<TargetClsName>Proxy`` with init args:
1243+
``(fname, group_path, parse_obj_kwargs)``.
1244+
"""
1245+
proxy_name = f"{target_cls.__name__}Proxy"
1246+
1247+
class _LazyProxy(target_cls):
1248+
def __init__(
1249+
self, fname: str, group_path: Optional[str], parse_obj_kwargs: Optional[dict[str, Any]]
1250+
):
1251+
object.__setattr__(self, "_lazy_fname", fname)
1252+
object.__setattr__(self, "_lazy_group_path", group_path)
1253+
object.__setattr__(self, "_lazy_parse_obj_kwargs", dict(parse_obj_kwargs or {}))
1254+
1255+
def copy(self, **kwargs):
1256+
"""Return another lazy proxy instead of materializing."""
1257+
return _LazyProxy(
1258+
self._lazy_fname,
1259+
self._lazy_group_path,
1260+
{**self._lazy_parse_obj_kwargs, **kwargs},
1261+
)
1262+
1263+
def __getattribute__(self, name: str):
1264+
if name in (
1265+
"__class__",
1266+
"__dict__",
1267+
"__weakref__",
1268+
"__post_root_validators__",
1269+
"copy", # <-- avoid materializing just for copy
1270+
) or name.startswith("_lazy_"):
1271+
return object.__getattribute__(self, name)
1272+
1273+
d = object.__getattribute__(self, "__dict__")
1274+
if "_lazy_fname" in d: # sentinel: not loaded yet
1275+
fname = d["_lazy_fname"]
1276+
group_path = d["_lazy_group_path"]
1277+
kwargs = d["_lazy_parse_obj_kwargs"]
1278+
1279+
model_dict = target_cls.dict_from_file(fname=fname, group_path=group_path)
1280+
target = target_cls.parse_obj(model_dict, **kwargs)
1281+
1282+
d.clear()
1283+
d.update(target.__dict__)
1284+
object.__setattr__(self, "__class__", target_cls)
1285+
object.__setattr__(self, "__fields_set__", set(target.__fields_set__))
1286+
private_attrs = getattr(target, "__private_attributes__", {}) or {}
1287+
for attr_name in private_attrs:
1288+
object.__setattr__(self, attr_name, getattr(target, attr_name))
1289+
1290+
if on_load is not None:
1291+
on_load(self)
1292+
1293+
return object.__getattribute__(self, name)
1294+
1295+
_LazyProxy.__name__ = proxy_name
1296+
return _LazyProxy

tidy3d/components/data/sim_data.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -903,6 +903,13 @@ class SimulationData(AbstractYeeGridSimulationData):
903903
sim_data.to_file(fname='path/to/file.hdf5') # Save a SimulationData object to a HDF5 file
904904
sim_data = SimulationData.from_file(fname='path/to/file.hdf5') # Load a SimulationData object from a HDF5 file.
905905
906+
Optionally, the simulation data can be loaded in a lazy mode, which only holds a reference until a field is accessed
907+
or a method is applied. This is useful to save I/O operations and memory.
908+
909+
.. code-block:: python
910+
911+
sim_data = SimulationData.from_file(fname='path/to/file.hdf5', lazy=True) # Does not contain data until accessed.
912+
906913
See Also
907914
--------
908915

tidy3d/web/api/tidy3d_stub.py

Lines changed: 71 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -82,24 +82,28 @@ def from_file(cls, file_path: str) -> WorkflowType:
8282

8383
data = json.loads(json_str)
8484
type_ = data["type"]
85-
if type_ == "Simulation":
86-
sim = Simulation.from_file(file_path)
87-
elif type_ == "ModeSolver":
88-
sim = ModeSolver.from_file(file_path)
89-
elif type_ == "HeatSimulation":
90-
sim = HeatSimulation.from_file(file_path)
91-
elif type_ == "HeatChargeSimulation":
92-
sim = HeatChargeSimulation.from_file(file_path)
93-
elif type_ == "EMESimulation":
94-
sim = EMESimulation.from_file(file_path)
95-
elif type_ == "ModeSimulation":
96-
sim = ModeSimulation.from_file(file_path)
97-
elif type_ == "VolumeMesher":
98-
sim = VolumeMesher.from_file(file_path)
99-
elif type_ == "ModalComponentModeler":
100-
sim = ModalComponentModeler.from_file(file_path)
101-
elif type_ == "TerminalComponentModeler":
102-
sim = TerminalComponentModeler.from_file(file_path)
85+
86+
supported_classes = [
87+
Simulation,
88+
ModeSolver,
89+
HeatSimulation,
90+
HeatChargeSimulation,
91+
EMESimulation,
92+
ModeSimulation,
93+
VolumeMesher,
94+
ModalComponentModeler,
95+
TerminalComponentModeler,
96+
]
97+
98+
class_map = {cls.__name__: cls for cls in supported_classes}
99+
100+
if type_ not in class_map:
101+
raise ValueError(
102+
f"Unsupported type '{type_}'. Supported types: {list(class_map.keys())}"
103+
)
104+
105+
sim_class = class_map[type_]
106+
sim = sim_class.from_file(file_path)
103107

104108
return sim
105109

@@ -202,7 +206,9 @@ class Tidy3dStubData(BaseModel, TaskStubData):
202206
data: WorkflowDataType
203207

204208
@classmethod
205-
def from_file(cls, file_path: str) -> WorkflowDataType:
209+
def from_file(
210+
cls, file_path: str, lazy: bool = False, on_load: Optional[Callable] = None
211+
) -> WorkflowDataType:
206212
"""Loads a Union[:class:`.SimulationData`, :class:`.HeatSimulationData`, :class:`.EMESimulationData`]
207213
from .yaml, .json, or .hdf5 file.
208214
@@ -211,6 +217,14 @@ def from_file(cls, file_path: str) -> WorkflowDataType:
211217
file_path : str
212218
Full path to the .yaml or .json or .hdf5 file to load the
213219
Union[:class:`.SimulationData`, :class:`.HeatSimulationData`, :class:`.EMESimulationData`] from.
220+
lazy : bool = False
221+
Whether to load the actual data (``lazy=False``) or return a proxy that loads
222+
the data when accessed (``lazy=True``).
223+
on_load : Callable | None = None
224+
Callback function executed once the model is fully materialized.
225+
Only used if ``lazy=True``. The callback is invoked with the loaded
226+
instance as its sole argument, enabling post-processing such as
227+
validation, logging, or warnings checks.
214228
215229
Returns
216230
-------
@@ -227,24 +241,28 @@ def from_file(cls, file_path: str) -> WorkflowDataType:
227241

228242
data = json.loads(json_str)
229243
type_ = data["type"]
230-
if type_ == "SimulationData":
231-
sim_data = SimulationData.from_file(file_path)
232-
elif type_ == "ModeSolverData":
233-
sim_data = ModeSolverData.from_file(file_path)
234-
elif type_ == "HeatSimulationData":
235-
sim_data = HeatSimulationData.from_file(file_path)
236-
elif type_ == "HeatChargeSimulationData":
237-
sim_data = HeatChargeSimulationData.from_file(file_path)
238-
elif type_ == "EMESimulationData":
239-
sim_data = EMESimulationData.from_file(file_path)
240-
elif type_ == "ModeSimulationData":
241-
sim_data = ModeSimulationData.from_file(file_path)
242-
elif type_ == "VolumeMesherData":
243-
sim_data = VolumeMesherData.from_file(file_path)
244-
elif type_ == "ModalComponentModelerData":
245-
sim_data = ModalComponentModelerData.from_file(file_path)
246-
elif type_ == "TerminalComponentModelerData":
247-
sim_data = TerminalComponentModelerData.from_file(file_path)
244+
245+
supported_data_classes = [
246+
SimulationData,
247+
ModeSolverData,
248+
HeatSimulationData,
249+
HeatChargeSimulationData,
250+
EMESimulationData,
251+
ModeSimulationData,
252+
VolumeMesherData,
253+
ModalComponentModelerData,
254+
TerminalComponentModelerData,
255+
]
256+
257+
data_class_map = {cls.__name__: cls for cls in supported_data_classes}
258+
259+
if type_ not in data_class_map:
260+
raise ValueError(
261+
f"Unsupported data type '{type_}'. Supported types: {list(data_class_map.keys())}"
262+
)
263+
264+
data_class = data_class_map[type_]
265+
sim_data = data_class.from_file(file_path, lazy=lazy, on_load=on_load)
248266

249267
return sim_data
250268

@@ -265,7 +283,7 @@ def to_file(self, file_path: str):
265283
self.data.to_file(file_path)
266284

267285
@classmethod
268-
def postprocess(cls, file_path: str) -> WorkflowDataType:
286+
def postprocess(cls, file_path: str, lazy: bool = True) -> WorkflowDataType:
269287
"""Load .yaml, .json, or .hdf5 file to
270288
Union[:class:`.SimulationData`, :class:`.HeatSimulationData`, :class:`.EMESimulationData`] instance.
271289
@@ -274,16 +292,28 @@ def postprocess(cls, file_path: str) -> WorkflowDataType:
274292
file_path : str
275293
Full path to the .yaml or .json or .hdf5 file to save the
276294
Union[:class:`.SimulationData`, :class:`.HeatSimulationData`, :class:`.EMESimulationData`] to.
295+
lazy : bool = False
296+
Whether to load the actual data (``lazy=False``) or return a proxy that loads
297+
the data when accessed (``lazy=True``).
277298
278299
Returns
279300
-------
280301
Union[:class:`.SimulationData`, :class:`.HeatSimulationData`, :class:`.EMESimulationData`]
281302
An instance of the component class calling ``load``.
282303
"""
283-
stub_data = Tidy3dStubData.from_file(file_path)
304+
stub_data = Tidy3dStubData.from_file(
305+
file_path, lazy=lazy, on_load=cls._check_convergence_and_warnings
306+
)
307+
if not lazy:
308+
cls._check_convergence_and_warnings(stub_data)
309+
return stub_data
284310

285-
check_log_msg = "For more information, check 'SimulationData.log' or use "
286-
check_log_msg += "'web.download_log(task_id)'."
311+
@staticmethod
312+
def _check_convergence_and_warnings(stub_data: WorkflowDataType) -> None:
313+
"""Check convergence, divergence, and warnings in the solver log and emit log messages."""
314+
check_log_msg = (
315+
"For more information, check 'SimulationData.log' or use 'web.download_log(task_id)'."
316+
)
287317
warned_about_warnings = False
288318

289319
if isinstance(stub_data, SimulationData):
@@ -313,5 +343,3 @@ def postprocess(cls, file_path: str) -> WorkflowDataType:
313343
and not warned_about_warnings
314344
):
315345
log.warning("Warning messages were found in the solver log. " + check_log_msg)
316-
317-
return stub_data

tidy3d/web/api/webapi.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -969,6 +969,7 @@ def load(
969969
replace_existing: bool = True,
970970
verbose: bool = True,
971971
progress_callback: Optional[Callable[[float], None]] = None,
972+
lazy: bool = False,
972973
) -> WorkflowDataType:
973974
"""
974975
Download and Load simulation results into :class:`.SimulationData` object.
@@ -998,6 +999,9 @@ def load(
998999
If ``True``, will print progressbars and status, otherwise, will run silently.
9991000
progress_callback : Callable[[float], None] = None
10001001
Optional callback function called when downloading file with ``bytes_in_chunk`` as argument.
1002+
lazy : bool = False
1003+
Whether to load the actual data (``lazy=False``) or return a proxy that loads
1004+
the data when accessed (``lazy=True``).
10011005
10021006
Returns
10031007
-------
@@ -1019,7 +1023,7 @@ def load(
10191023
else:
10201024
console.log(f"loading simulation from {path}")
10211025

1022-
stub_data = Tidy3dStubData.postprocess(path)
1026+
stub_data = Tidy3dStubData.postprocess(path, lazy=lazy)
10231027
return stub_data
10241028

10251029

0 commit comments

Comments
 (0)