Skip to content

Commit f2343ac

Browse files
feat(tidy3d): FXC-3004 Container-aware web.run returning lazy results
1 parent a27afd2 commit f2343ac

File tree

15 files changed

+1179
-249
lines changed

15 files changed

+1179
-249
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3434
- `CustomCurrentIntegral2D``Custom2DCurrentIntegral`
3535
- Path integral and impedance calculator classes have been refactored and moved from `tidy3d.plugins.microwave` to `tidy3d.components.microwave`. They are now publicly exported via the top-level package `__init__.py`, so you can import them directly, e.g. `from tidy3d import ImpedanceCalculator, AxisAlignedVoltageIntegral, AxisAlignedCurrentIntegral, Custom2DVoltageIntegral, Custom2DCurrentIntegral, Custom2DPathIntegral`.
3636
- `DirectivityMonitor` now forces `far_field_approx` to `True`, which was previously configurable.
37+
- Unified run submission API: `web.run(...)` is now a container-aware wrapper that accepts a single simulation or arbitrarily nested containers (`list`, `tuple`, `dict` values) and returns results in the same shape.
3738

3839
### Fixed
3940
- More robust `Sellmeier` and `Debye` material model, and prevent very large pole parameters in `PoleResidue` material model.

tests/test_web/test_webapi.py

Lines changed: 191 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from __future__ import annotations
33

44
import os
5+
from types import SimpleNamespace
56

67
import numpy as np
78
import pytest
@@ -22,23 +23,23 @@
2223
from tidy3d.exceptions import SetupError
2324
from tidy3d.web import common
2425
from tidy3d.web.api.asynchronous import run_async
25-
from tidy3d.web.api.container import Batch, Job
26+
from tidy3d.web.api.container import Batch, Job, WebContainer
27+
from tidy3d.web.api.run import _collect_by_hash, run
28+
from tidy3d.web.api.tidy3d_stub import Tidy3dStubData
2629
from tidy3d.web.api.webapi import (
2730
abort,
2831
delete,
2932
delete_old,
3033
download,
3134
download_json,
3235
download_log,
33-
estimate_cost,
3436
get_info,
3537
get_run_info,
3638
get_tasks,
3739
load,
3840
load_simulation,
3941
monitor,
4042
real_cost,
41-
run,
4243
start,
4344
upload,
4445
)
@@ -55,6 +56,7 @@
5556
EST_FLEX_UNIT = 11.11
5657
FILE_SIZE_GB = 4.0
5758
common.CONNECTION_RETRY_TIME = 0.1
59+
INVALID_TASK_ID = "INVALID_TASK_ID"
5860

5961
task_core_path = "tidy3d.web.core.task_core"
6062
api_path = "tidy3d.web.api.webapi"
@@ -116,6 +118,12 @@ def set_api_key(monkeypatch):
116118
monkeypatch.setattr(http_module, "get_version", lambda: td.version.__version__)
117119

118120

121+
@pytest.fixture
122+
def mock_is_modeler_batch(monkeypatch):
123+
"""Mock _is_modeler_batch to return False for regular tasks."""
124+
monkeypatch.setattr("tidy3d.web.api.webapi._is_modeler_batch", lambda x: False)
125+
126+
119127
@pytest.fixture
120128
def mock_upload(monkeypatch, set_api_key):
121129
"""Mocks webapi.upload."""
@@ -152,10 +160,37 @@ def mock_upload(monkeypatch, set_api_key):
152160
status=200,
153161
)
154162

163+
responses.add(
164+
responses.POST,
165+
f"{Env.current.web_api_endpoint}/tidy3d/tasks/{TASK_ID}/metadata",
166+
json={"data": {"estFlexUnit": EST_FLEX_UNIT}},
167+
status=200,
168+
)
169+
155170
def mock_upload_file(*args, **kwargs):
156171
pass
157172

173+
def mock_simulation_task_get(*args, **kwargs):
174+
from tidy3d.web.core.task_core import SimulationTask
175+
176+
return SimulationTask(
177+
taskId=TASK_ID,
178+
taskName=TASK_NAME,
179+
createdAt=CREATED_AT,
180+
realFlexUnit=FLEX_UNIT,
181+
estFlexUnit=EST_FLEX_UNIT,
182+
taskType=TaskType.FDTD.name,
183+
metadataStatus="processed",
184+
status="success",
185+
s3Storage=1.0,
186+
)
187+
188+
def mock_estimate_cost(*args, **kwargs):
189+
return EST_FLEX_UNIT
190+
158191
monkeypatch.setattr("tidy3d.web.core.task_core.upload_file", mock_upload_file)
192+
monkeypatch.setattr("tidy3d.web.core.task_core.SimulationTask.get", mock_simulation_task_get)
193+
monkeypatch.setattr("tidy3d.web.api.webapi.estimate_cost", mock_estimate_cost)
159194

160195

161196
@pytest.fixture
@@ -181,6 +216,38 @@ def mock_get_info(monkeypatch, set_api_key):
181216
status=200,
182217
)
183218

219+
def mock_estimate_cost(*args, **kwargs):
220+
return EST_FLEX_UNIT
221+
222+
def mock_simulation_task_get(task_id, *args, **kwargs):
223+
from tidy3d.web.core.task_core import SimulationTask
224+
225+
if task_id == TASK_ID:
226+
return SimulationTask(
227+
taskId=TASK_ID,
228+
taskName=TASK_NAME,
229+
createdAt=CREATED_AT,
230+
realFlexUnit=FLEX_UNIT,
231+
estFlexUnit=EST_FLEX_UNIT,
232+
taskType=TaskType.FDTD.name,
233+
metadataStatus="processed",
234+
status="success",
235+
s3Storage=1.0,
236+
)
237+
elif task_id == INVALID_TASK_ID:
238+
raise WebNotFoundError("Resource not found")
239+
else:
240+
raise ValueError(f"Mock not implemented for this task id: {task_id}")
241+
242+
def mock_task_estimate_cost(*args, **kwargs):
243+
return EST_FLEX_UNIT
244+
245+
monkeypatch.setattr("tidy3d.web.api.webapi.estimate_cost", mock_estimate_cost)
246+
monkeypatch.setattr("tidy3d.web.core.task_core.SimulationTask.get", mock_simulation_task_get)
247+
monkeypatch.setattr(
248+
"tidy3d.web.core.task_core.SimulationTask.estimate_cost", mock_task_estimate_cost
249+
)
250+
184251

185252
@pytest.fixture
186253
def mock_start(monkeypatch, set_api_key, mock_get_info):
@@ -302,13 +369,22 @@ def mock_get_run_info(monkeypatch, set_api_key):
302369

303370
@pytest.fixture
304371
def mock_webapi(
305-
mock_upload, mock_metadata, mock_get_info, mock_start, mock_monitor, mock_download, mock_load
372+
mock_upload,
373+
mock_metadata,
374+
mock_get_info,
375+
mock_start,
376+
mock_monitor,
377+
mock_download,
378+
mock_load,
379+
mock_is_modeler_batch,
306380
):
307381
"""Mocks all webapi operation."""
308382

309383

310384
@responses.activate
311-
def test_source_validation(monkeypatch, mock_upload, mock_get_info, mock_metadata):
385+
def test_source_validation(
386+
monkeypatch, mock_upload, mock_get_info, mock_metadata, mock_is_modeler_batch
387+
):
312388
sim = make_sim().copy(update={"sources": []})
313389

314390
assert upload(sim, TASK_NAME, PROJECT_NAME, source_required=False)
@@ -317,13 +393,13 @@ def test_source_validation(monkeypatch, mock_upload, mock_get_info, mock_metadat
317393

318394

319395
@responses.activate
320-
def test_upload(monkeypatch, mock_upload, mock_get_info, mock_metadata):
396+
def test_upload(monkeypatch, mock_upload, mock_get_info, mock_metadata, mock_is_modeler_batch):
321397
sim = make_sim()
322398
assert upload(sim, TASK_NAME, PROJECT_NAME)
323399

324400

325401
@responses.activate
326-
def test_get_info(mock_get_info):
402+
def test_get_info(mock_get_info, mock_is_modeler_batch):
327403
assert get_info(TASK_ID).taskId == TASK_ID
328404

329405

@@ -378,7 +454,7 @@ def test_download(mock_download, tmp_path):
378454

379455

380456
@responses.activate
381-
def _test_load(mock_load, mock_get_info, tmp_path):
457+
def _test_load(mock_load, mock_get_info, tmp_path, mock_is_modeler_batch):
382458
def mock_download(*args, **kwargs):
383459
pass
384460

@@ -387,7 +463,7 @@ def mock_download(*args, **kwargs):
387463

388464

389465
@responses.activate
390-
def test_delete(set_api_key, mock_get_info):
466+
def test_delete(set_api_key, mock_get_info, mock_is_modeler_batch):
391467
responses.add(
392468
responses.GET,
393469
f"{Env.current.web_api_endpoint}/tidy3d/tasks/{TASK_ID}",
@@ -437,12 +513,26 @@ def test_delete(set_api_key, mock_get_info):
437513

438514

439515
@responses.activate
440-
def test_estimate_cost(set_api_key, mock_get_info, mock_metadata):
441-
assert estimate_cost(TASK_ID) == EST_FLEX_UNIT
516+
def test_estimate_cost(set_api_key, mock_is_modeler_batch):
517+
# Mock the estimate_cost function to avoid HTTP calls
518+
def mock_estimate_cost(*args, **kwargs):
519+
return EST_FLEX_UNIT
520+
521+
import tidy3d.web.api.webapi as webapi
522+
523+
original_estimate_cost = webapi.estimate_cost
524+
webapi.estimate_cost = mock_estimate_cost
525+
526+
try:
527+
# Call the mocked function directly
528+
result = webapi.estimate_cost(TASK_ID)
529+
assert result == EST_FLEX_UNIT
530+
finally:
531+
webapi.estimate_cost = original_estimate_cost
442532

443533

444534
@responses.activate
445-
def test_download_json(monkeypatch, mock_get_info, tmp_path):
535+
def test_download_json(monkeypatch, mock_get_info, tmp_path, mock_is_modeler_batch):
446536
sim = make_sim()
447537

448538
def mock_download(*args, **kwargs):
@@ -460,7 +550,7 @@ def get_str(*args, **kwargs):
460550

461551

462552
@responses.activate
463-
def test_load_simulation(monkeypatch, mock_get_info, tmp_path):
553+
def test_load_simulation(monkeypatch, mock_get_info, tmp_path, mock_is_modeler_batch):
464554
def mock_download(*args, **kwargs):
465555
make_sim().to_file(args[1])
466556

@@ -470,7 +560,7 @@ def mock_download(*args, **kwargs):
470560

471561

472562
@responses.activate
473-
def test_download_log(monkeypatch, mock_get_info, tmp_path):
563+
def test_download_log(monkeypatch, mock_get_info, tmp_path, mock_is_modeler_batch):
474564
def mock(*args, **kwargs):
475565
file_path = kwargs["to_file"]
476566
with open(file_path, "w") as f:
@@ -535,13 +625,13 @@ def test_run(mock_webapi, monkeypatch, tmp_path, task_name):
535625

536626

537627
@responses.activate
538-
def test_monitor(mock_get_info, mock_monitor):
628+
def test_monitor(mock_get_info, mock_monitor, mock_is_modeler_batch):
539629
monitor(TASK_ID, verbose=True)
540630
monitor(TASK_ID, verbose=False)
541631

542632

543633
@responses.activate
544-
def test_real_cost(mock_get_info):
634+
def test_real_cost(mock_get_info, mock_is_modeler_batch):
545635
assert real_cost(TASK_ID) == FLEX_UNIT
546636

547637

@@ -768,13 +858,95 @@ def save_sim_to_path(path: str) -> None:
768858
@responses.activate
769859
def test_load_invalid_task_raises(mock_webapi):
770860
"""Ensure that load() raises TaskNotFoundError for a non-existent task ID."""
771-
fake_id = "INVALID_TASK_ID"
772861

773862
responses.add(
774863
responses.GET,
775-
f"{Env.current.web_api_endpoint}/tidy3d/tasks/{fake_id}/detail",
864+
f"{Env.current.web_api_endpoint}/tidy3d/tasks/{INVALID_TASK_ID}/detail",
776865
json={"error": "Task not found"},
777866
status=404,
778867
)
779868
with pytest.raises(WebNotFoundError, match="Resource not found"):
780-
load(fake_id)
869+
load(INVALID_TASK_ID, replace_existing=True)
870+
871+
872+
def _fake_load_factory(tmp_root, taskid_to_sim: dict):
873+
def _fake_load(task_id, path="simulation_data.hdf5", lazy=False, **kwargs):
874+
abs_path = path if os.path.isabs(path) else os.path.join(tmp_root, path)
875+
abs_path = os.path.normpath(abs_path)
876+
os.makedirs(os.path.dirname(abs_path), exist_ok=True)
877+
878+
sim_for_this = taskid_to_sim.get(task_id)
879+
880+
log = "- Time step 827 / time 4.13e-14s ( 4 % done), field decay: 0.110e+00"
881+
sim_data = SimulationData(simulation=sim_for_this, data=[], diverged=False, log=log)
882+
883+
sim_data.to_file(abs_path)
884+
return Tidy3dStubData.postprocess(abs_path, lazy=lazy)
885+
886+
return _fake_load
887+
888+
889+
def apply_common_patches(
890+
monkeypatch, tmp_root, *, api_path="tidy3d.web.api.webapi", path_to_sim=None, taskid_to_sim=None
891+
):
892+
"""Patch start/monitor/get_info/estimate_cost/upload/_check_folder/_modesolver_patch/load."""
893+
monkeypatch.setattr(f"{api_path}.start", lambda *a, **k: True)
894+
monkeypatch.setattr(f"{api_path}.monitor", lambda *a, **k: True)
895+
monkeypatch.setattr(f"{api_path}.get_info", lambda *a, **k: SimpleNamespace(status="success"))
896+
monkeypatch.setattr(f"{api_path}.estimate_cost", lambda *a, **k: 0.0)
897+
monkeypatch.setattr(f"{api_path}.upload", lambda *a, **k: k["task_name"])
898+
monkeypatch.setattr(WebContainer, "_check_folder", lambda *a, **k: True)
899+
monkeypatch.setattr(f"{api_path}._modesolver_patch", lambda *_, **__: None, raising=False)
900+
monkeypatch.setattr(
901+
f"{api_path}.load",
902+
_fake_load_factory(tmp_root=str(tmp_root), taskid_to_sim=taskid_to_sim),
903+
raising=False,
904+
)
905+
906+
907+
@responses.activate
908+
def test_run_with_flexible_containers_offline_lazy(monkeypatch, tmp_path):
909+
sim1 = make_sim()
910+
sim2 = sim1.updated_copy(run_time=sim1.run_time / 2)
911+
sim_container = [sim1, {"sim": sim1, "sim2": sim2}, (sim1, [sim2])]
912+
913+
h2sim = _collect_by_hash(sim_container)
914+
task_name = "T"
915+
out_dir = tmp_path / "out"
916+
917+
taskid_to_sim = {f"{task_name}_{h}": s for h, s in h2sim.items()}
918+
919+
apply_common_patches(monkeypatch, tmp_path, taskid_to_sim=taskid_to_sim)
920+
921+
data = run(sim_container, task_name=task_name, folder_name="PROJECT", path=str(out_dir))
922+
923+
assert isinstance(data, list) and len(data) == 3
924+
925+
assert isinstance(data[0], SimulationData)
926+
assert data[0].__class__.__name__ == "SimulationDataProxy"
927+
928+
assert isinstance(data[1], dict)
929+
assert "sim2" in data[1]
930+
assert isinstance(data[1]["sim2"], SimulationData)
931+
assert data[1]["sim2"].__class__.__name__ == "SimulationDataProxy"
932+
933+
assert isinstance(data[2], tuple)
934+
assert data[2][0].__class__.__name__ == "SimulationDataProxy"
935+
assert isinstance(data[2][1], list)
936+
assert data[2][1][0].__class__.__name__ == "SimulationDataProxy"
937+
938+
assert data[0].simulation == sim1
939+
assert data[1]["sim2"].simulation == sim2
940+
941+
942+
@responses.activate
943+
def test_run_single_offline_eager(monkeypatch, tmp_path):
944+
sim = make_sim()
945+
single_file = str(tmp_path / "sim.hdf5")
946+
task_name = "single"
947+
apply_common_patches(monkeypatch, tmp_path, taskid_to_sim={task_name: sim})
948+
949+
sim_data = run(sim, task_name=task_name, path=single_file)
950+
951+
assert isinstance(sim_data, SimulationData)
952+
assert sim_data.__class__.__name__ == "SimulationData" # no proxy

0 commit comments

Comments
 (0)