diff --git a/CHANGELOG.md b/CHANGELOG.md index 61d487171e..7e8fa6d2c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,7 +26,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Validation for `run_only` field in component modelers to catch duplicate or invalid matrix indices early with clear error messages. - Introduced a profile-based configuration manager with TOML persistence and runtime overrides exposed via `tidy3d.config`. - Added support of `os.PathLike` objects as paths like `pathlib.Path` alongside `str` paths in all path-related functions. -- Added configurable local simulation result caching with checksum validation, eviction limits, and per-call overrides across `web.run`, `web.load`, and job workflows. +- Added local simulation result caching to avoid rerunning identical simulations. Enable and configure it through `td.config.local_cache` (size limits, cache directory). Use CLI commands `tidy3d cache {info, list, clear}` to inspect or clear the cache. - Added `DirectivityMonitorSpec` for automated creation and configuration of directivity radiation monitors in `TerminalComponentModeler`. - Added multimode support to `WavePort` in the smatrix plugin, allowing multiple modes to be analyzed per port. diff --git a/tests/test_web/test_local_cache.py b/tests/test_web/test_local_cache.py index 824bb69283..781fae77e6 100644 --- a/tests/test_web/test_local_cache.py +++ b/tests/test_web/test_local_cache.py @@ -13,6 +13,7 @@ import pytest import xarray as xr from autograd.core import defvjp +from click.testing import CliRunner from rich.console import Console import tidy3d as td @@ -37,6 +38,7 @@ get_cache_entry_dir, resolve_local_cache, ) +from tidy3d.web.cli.app import tidy3d_cli from tidy3d.web.core.task_core import BatchTask common.CONNECTION_RETRY_TIME = 0.1 @@ -724,6 +726,49 @@ def _test_env_var_overrides(monkeypatch, tmp_path): manager._reload() +def _test_cache_cli_commands(monkeypatch, tmp_path_factory, basic_simulation): + runner = CliRunner() + cache_dir = tmp_path_factory.mktemp("cli_cache") + artifact_dir = tmp_path_factory.mktemp("cli_cache_artifact") + + monkeypatch.setattr(config.local_cache, "enabled", True) + monkeypatch.setattr(config.local_cache, "directory", cache_dir) + monkeypatch.setattr(config.local_cache, "max_entries", 3) + monkeypatch.setattr(config.local_cache, "max_size_gb", 2.5) + + cache = resolve_local_cache(use_cache=True) + cache.clear() + + artifact = artifact_dir / CACHE_ARTIFACT_NAME + artifact.write_text("payload_cli") + cache.store_result( + _FakeStubData(basic_simulation), f"{MOCK_TASK_ID}-cli", str(artifact), "FDTD" + ) + + info_result = runner.invoke(tidy3d_cli, ["cache", "info"]) + assert info_result.exit_code == 0 + assert "Enabled: yes" in info_result.output + assert "Entries: 1" in info_result.output + assert "Max entries: 3" in info_result.output + assert "Max size: 2.50 GB" in info_result.output + + list_result = runner.invoke(tidy3d_cli, ["cache", "list"]) + assert list_result.exit_code == 0 + out = list_result.output + assert "Cache Entry #1" in out + assert "Workflow type: FDTD" in out + assert "File size:" in out + + clear_result = runner.invoke(tidy3d_cli, ["cache", "clear"]) + assert clear_result.exit_code == 0 + assert "Local cache cleared." in clear_result.output + assert len(cache) == 0 + + list_after = runner.invoke(tidy3d_cli, ["cache", "list"]) + assert list_after.exit_code == 0 + assert "Cache is empty." in list_after.output + + def test_cache_sequential( monkeypatch, tmp_path, tmp_path_factory, basic_simulation, fake_data, request ): @@ -746,3 +791,4 @@ def test_cache_sequential( _test_store_and_fetch_do_not_iterate(monkeypatch, tmp_path, basic_simulation) _test_mode_solver_caching(monkeypatch, tmp_path) _test_verbosity(monkeypatch, basic_simulation) + _test_cache_cli_commands(monkeypatch, tmp_path_factory, basic_simulation) diff --git a/tidy3d/web/cache.py b/tidy3d/web/cache.py index e7a6ef32af..5c96a7a860 100644 --- a/tidy3d/web/cache.py +++ b/tidy3d/web/cache.py @@ -842,8 +842,16 @@ def resolve_local_cache(use_cache: Optional[bool] = None) -> Optional[LocalCache return None if _CACHE is not None and _CACHE._root != Path(config.local_cache.directory): - log.debug(f"Clearing old cache directory {_CACHE._root}") - _CACHE.clear(hard=True) + old_root = _CACHE._root + new_root = Path(config.local_cache.directory) + log.debug(f"Moving cache directory from {old_root} → {new_root}") + try: + new_root.parent.mkdir(parents=True, exist_ok=True) + if old_root.exists(): + shutil.move(old_root, new_root) + except Exception as e: + log.warning(f"Failed to move cache directory: {e}. Delete old cache.") + shutil.rmtree(old_root) _CACHE = LocalCache( directory=config.local_cache.directory, diff --git a/tidy3d/web/cli/app.py b/tidy3d/web/cli/app.py index d00599b2af..173b962ff5 100644 --- a/tidy3d/web/cli/app.py +++ b/tidy3d/web/cli/app.py @@ -18,6 +18,7 @@ legacy_config_directory, migrate_legacy_config, ) +from tidy3d.web.cli.cache import cache_group from tidy3d.web.cli.constants import TIDY3D_DIR from tidy3d.web.core.constants import HEADER_APIKEY @@ -217,3 +218,4 @@ def config_group() -> None: tidy3d_cli.add_command(convert) tidy3d_cli.add_command(develop) tidy3d_cli.add_command(config_group, name="config") +tidy3d_cli.add_command(cache_group) diff --git a/tidy3d/web/cli/cache.py b/tidy3d/web/cli/cache.py new file mode 100644 index 0000000000..8d9c5454cf --- /dev/null +++ b/tidy3d/web/cli/cache.py @@ -0,0 +1,95 @@ +"""Cache-related CLI commands.""" + +from __future__ import annotations + +from typing import Optional + +import click + +from tidy3d import config +from tidy3d.web.cache import LocalCache, resolve_local_cache +from tidy3d.web.cache import clear as clear_cache + + +def _fmt_size(num_bytes: int) -> str: + """Format bytes into human-readable form.""" + for unit in ["B", "KB", "MB", "GB", "TB"]: + if num_bytes < 1024.0 or unit == "TB": + return f"{num_bytes:.2f} {unit}" + num_bytes /= 1024.0 + return f"{num_bytes:.2f} B" # fallback, though unreachable + + +def _get_cache(ensure: bool = True) -> Optional[LocalCache]: + """Resolve the local cache object, surfacing errors as ClickExceptions.""" + try: + cache = resolve_local_cache(use_cache=True) + except Exception as exc: # pragma: no cover - defensive guard + raise click.ClickException(f"Failed to access local cache: {exc}") from exc + if cache is None and ensure: + raise click.ClickException("Local cache is disabled in the current configuration.") + return cache + + +@click.group(name="cache") +def cache_group() -> None: + """Inspect or manage the local cache.""" + + +@cache_group.command() +def info() -> None: + """Display current cache configuration and usage statistics.""" + + enabled = bool(config.local_cache.enabled) + directory = config.local_cache.directory + max_entries = config.local_cache.max_entries + max_size_gb = config.local_cache.max_size_gb + + cache = _get_cache(ensure=False) + entries = 0 + total_size = 0 + if cache is not None: + stats = cache.sync_stats() + entries = stats.total_entries + total_size = stats.total_size + + click.echo(f"Enabled: {'yes' if enabled else 'no'}") + click.echo(f"Directory: {directory}") + click.echo(f"Entries: {entries}") + click.echo(f"Total size: {_fmt_size(total_size)}") + click.echo("Max entries: " + (str(max_entries) if max_entries else "unlimited")) + click.echo( + "Max size: " + (f"{_fmt_size(max_size_gb * 1024**3)}" if max_size_gb else "unlimited") + ) + + +@cache_group.command(name="list") +def list() -> None: + """List cached entries in a readable, separated format.""" + + cache = _get_cache() + entries = cache.list() + if not entries: + click.echo("Cache is empty.") + return + + def fmt_key(key: str) -> str: + return key.replace("_", " ").capitalize() + + for i, entry in enumerate(entries, start=1): + entry.pop("simulation_hash", None) + entry.pop("checksum", None) + entry["file_size"] = _fmt_size(entry["file_size"]) + + click.echo(f"\n=== Cache Entry #{i} ===") + for k, v in entry.items(): + click.echo(f"{fmt_key(k)}: {v}") + click.echo("") + + +@cache_group.command() +def clear() -> None: + """Remove all cache contents.""" + + clear_cache() + click.echo("Local cache cleared.")