Run cache, read path: cymsim run --cache URL (CYMSIM_CACHE_URL), simulate(cfg, cache=URL); a covering entry is cut without building the model, a shorter one is continued; local copies revalidated by ETag
5 changed files+355−17
README.mdmodified+4−1View file
@@ -23,8 +23,11 @@ cymsim run my_run.yaml -o run.cymsim --record-acceleration # include the audio
2323 cymsim run my_run.yaml --resume run.cymsim --duration 4 -o run4.cymsim # continue a saved run to 4 s
2424 cymsim info run.cymsim # summarise a .cymsim file
2525 cymsim cache-key my_run.yaml --show # the run's cache key (independent of the duration) and what it hashes
26+cymsim run my_run.yaml --cache https://artifact-cache.tempory.net/api/apps/cymsim # take the run from the run cache, or continue a shorter cached run
2627 ```
2728
29+With `--cache URL` (or `CYMSIM_CACHE_URL` set), a run is looked up in a run cache under its key (`cymsim cache-key`), which leaves out the duration and the output naming. The cache holds the longest run of each key so far as a `.cymsim` file without the acceleration record: a run it covers is cut from it without building the model, and a longer run continues from it (`--resume`), identical to an uninterrupted run on the same device. Downloads are kept in `~/.cache/cymsim` (`CYMSIM_CACHE_DIR`) and revalidated by ETag. See [cache.py](src/cymsim/cache.py).
30+
2831 A `.cymsim` file is an HDF5 file holding the configuration, the mesh, the listener signals, displacement frames at a low rate, optionally the normal acceleration of every node at the audio rate, and the integrator state; the layout is specified in [docs/format.md](docs/format.md). It is meant for taking the shell's motion into other programs (other listeners, rooms, acoustic solvers) and is the file the web companion cymsimweb saves and loads. A partial file written during a run is a consistent prefix of the complete one.
2932
3033 The same from Python:
@@ -65,7 +68,7 @@ A run is described by one `SimulationConfig` made of `Material`, `Profile`, `Imp
6568 | [decimation.py](src/cymsim/decimation.py) | the anti-aliasing kernel that takes accelerations and pressure from the sub-step rate to the audio rate |
6669 | [simulate.py](src/cymsim/simulate.py) | leapfrog time loop, block compilation, acceleration and displacement records, result container |
6770 | [cymsim_file.py](src/cymsim/cymsim_file.py) | the .cymsim HDF5 file: streaming writer, loader, summary |
68-| [cache.py](src/cymsim/cache.py) | cache keys: hash of the non-default config fields (without duration and output naming) and `MODEL_VERSION` |
71+| [cache.py](src/cymsim/cache.py) | run cache: keys (hash of the non-default config fields without duration and output naming, and `MODEL_VERSION`), download, cut or continue |
6972 | [diagnostics.py](src/cymsim/diagnostics.py) | band envelopes, onset times, spectral centroid, cascade plot |
7073 | [cli.py](src/cymsim/cli.py) | `cymsim simulate` (`run`), `cymsim check`, `cymsim info`, `cymsim cache-key`, `cymsim example-config` |
7174
src/cymsim/cache.pymodified+89−1View file
@@ -1,4 +1,4 @@
1-"""Cache keys for runs.
1+"""A cache of runs at an artifact-cache app (https://artifact-cache.tempory.net), and its keys.
22
33 A cached run is identified by its configuration without CONTINUATION_FIELDS (the duration, the
44 energy-report cadence and the output naming), so that one cache entry serves every duration: a
@@ -9,15 +9,27 @@ new config field whose default reproduces the old behaviour therefore leaves eve
99 unchanged. Bump MODEL_VERSION by hand whenever the results of some existing configuration change:
1010 a change to the numerics, the mesher, the radiation model, or the value of a default. The test
1111 suite pins a fingerprint of the defaults as a reminder.
12+
13+An entry is the .cymsim file of the longest run of its key so far, without the acceleration
14+record. ``cached_run`` reads it at ``<url>/entries/<key>`` (public): a run it covers is cut from
15+it, a run it does not cover continues from it, and without an entry the run starts from rest.
16+Downloads are kept in CYMSIM_CACHE_DIR (default ~/.cache/cymsim) and revalidated by ETag.
1217 """
1318 from __future__ import annotations
1419
1520 import dataclasses
1621 import hashlib
1722 import json
23+import logging
24+import os
25+import urllib.error
26+import urllib.request
27+from pathlib import Path
1828
1929 from .config import CONTINUATION_FIELDS, SimulationConfig
2030
31+log = logging.getLogger("cymsim")
32+
2133 MODEL_VERSION = 1
2234
2335
@@ -52,3 +64,79 @@ def cache_key(cfg: SimulationConfig) -> str:
5264 text = json.dumps({"model_version": MODEL_VERSION, "config": key_fields(cfg)}, sort_keys=True,
5365 separators=(",", ":"))
5466 return f"v{MODEL_VERSION}/{hashlib.sha256(text.encode()).hexdigest()}"
67+
68+
69+def cache_dir() -> Path:
70+ return Path(os.environ.get("CYMSIM_CACHE_DIR") or Path.home() / ".cache" / "cymsim")
71+
72+
73+def fetch(url: str, key: str, directory: Path | None = None) -> Path | None:
74+ """The local copy of the entry ``key`` at the cache ``url``, downloaded or revalidated, or None
75+ if there is no such entry. If the cache cannot be reached, an earlier local copy is used."""
76+ path = (directory or cache_dir()) / f"{key}.cymsim"
77+ etag_path = path.with_name(path.name + ".etag")
78+ from . import __version__
79+ # (Cloudflare refuses urllib's default User-Agent)
80+ req = urllib.request.Request(f"{url.rstrip('/')}/entries/{key}", headers={"User-Agent": f"cymsim/{__version__}"})
81+ if path.exists() and etag_path.exists():
82+ req.add_header("If-None-Match", etag_path.read_text())
83+ try:
84+ with urllib.request.urlopen(req, timeout=60) as resp:
85+ path.parent.mkdir(parents=True, exist_ok=True)
86+ tmp = path.with_name(path.name + ".part")
87+ with open(tmp, "wb") as f:
88+ while chunk := resp.read(1 << 20):
89+ f.write(chunk)
90+ tmp.replace(path)
91+ etag = resp.headers.get("ETag")
92+ if etag:
93+ etag_path.write_text(etag)
94+ else:
95+ etag_path.unlink(missing_ok=True)
96+ log.info("cache: downloaded %s (%.1f MB)", key, path.stat().st_size / 1e6)
97+ return path
98+ except urllib.error.HTTPError as e:
99+ if e.code == 304:
100+ log.info("cache: local copy of %s is current", key)
101+ return path
102+ if e.code == 404:
103+ path.unlink(missing_ok=True)
104+ etag_path.unlink(missing_ok=True)
105+ return None
106+ raise
107+ except urllib.error.URLError as e:
108+ if path.exists():
109+ log.warning("cache: %s unreachable (%s); using the local copy of %s", url, e.reason, key)
110+ return path
111+ raise
112+
113+
114+def cached_run(config: SimulationConfig, url: str, save_path: str | None = None,
115+ save_every: float | None = None, directory: Path | None = None):
116+ """The run of ``config``, taken from or continued from the cache at ``url`` where possible
117+ (see the module docstring). Nothing is uploaded."""
118+ from .cymsim_file import load_cymsim, save_cymsim
119+ from .simulate import Simulator, cut_run
120+
121+ if config.output.record_acceleration:
122+ log.warning("cache: runs with the acceleration record are not cached; running without the cache")
123+ return Simulator(config).run(save_path=save_path, save_every=save_every)
124+ key = cache_key(config)
125+ local = fetch(url, key, directory)
126+ prev = None
127+ if local is None:
128+ log.info("cache: no entry for %s", key)
129+ else:
130+ prev = load_cymsim(local)
131+ if cache_key(prev.config) != key:
132+ log.warning("cache: the entry at %s holds a run of a different configuration; ignoring it", key)
133+ prev = None
134+ else:
135+ cut = cut_run(prev, config)
136+ if cut is not None:
137+ log.info("cache: %s covers the run (%.3f s cached)", key, len(prev.pressure_rayleigh) / prev.fs)
138+ if save_path is not None:
139+ save_cymsim(cut, save_path)
140+ return cut
141+ log.info("cache: %s holds %.3f s; continuing from there", key, len(prev.pressure_rayleigh) / prev.fs)
142+ return Simulator(config).run(save_path=save_path, save_every=save_every, resume=prev)
src/cymsim/cli.pymodified+27−12View file
@@ -4,7 +4,9 @@ from __future__ import annotations
44
55 import argparse
66 import logging
7+import os
78 import sys
9+import time
810
911 from .config import SimulationConfig, default_config
1012
@@ -31,18 +33,27 @@ def cmd_simulate(args) -> int:
3133 from .diagnostics import analyze_cascade, format_cascade_report, plot_cascade
3234 from .simulate import Simulator
3335
36+ t0 = time.time()
3437 cfg = _load(args.config, args)
35- sim = Simulator(cfg)
36- prev = None
37- if args.resume is not None:
38- from .cymsim_file import load_cymsim
39- prev = load_cymsim(args.resume)
40- try:
41- sim.check_resumable(prev)
42- except ValueError as e:
43- print(f"cannot continue {args.resume}: {e}", file=sys.stderr)
44- return 2
45- result = sim.run(save_path=args.output, save_every=args.save_every, resume=prev)
38+ cache = None if args.no_cache or args.resume is not None else args.cache or os.environ.get("CYMSIM_CACHE_URL")
39+ if args.cache and args.resume is not None:
40+ print("give either --cache or --resume, not both", file=sys.stderr)
41+ return 2
42+ if cache:
43+ from .cache import cached_run
44+ result = cached_run(cfg, cache, save_path=args.output, save_every=args.save_every)
45+ else:
46+ sim = Simulator(cfg)
47+ prev = None
48+ if args.resume is not None:
49+ from .cymsim_file import load_cymsim
50+ prev = load_cymsim(args.resume)
51+ try:
52+ sim.check_resumable(prev)
53+ except ValueError as e:
54+ print(f"cannot continue {args.resume}: {e}", file=sys.stderr)
55+ return 2
56+ result = sim.run(save_path=args.output, save_every=args.save_every, resume=prev)
4657 prefix = cfg.output.prefix if cfg.numerics.nonlinear else cfg.output.prefix + "_linear"
4758 if cfg.output.wav:
4859 print("wrote " + ", ".join(result.write_wav(prefix)))
@@ -55,7 +66,7 @@ def cmd_simulate(args) -> int:
5566 print("wrote " + plot_cascade(analysis, f"{prefix}_cascade.png", f"{prefix}_unbaffled", result.fs))
5667 except ImportError:
5768 print("matplotlib not available; skipping the cascade plot")
58- print(f"total wall time {sim.setup_time + result.wall_time:.0f} s")
69+ print(f"total wall time {time.time() - t0:.0f} s")
5970 return 0
6071
6172
@@ -120,6 +131,10 @@ def main(argv=None) -> int:
120131 p.add_argument("--resume", metavar="FILE.cymsim",
121132 help="continue the run saved in FILE.cymsim (same configuration up to the duration and the "
122133 "output naming) instead of starting from rest")
134+ p.add_argument("--cache", metavar="URL",
135+ help="take the run from, or continue it from, the run cache at URL (an artifact-cache app, "
136+ "e.g. https://artifact-cache.tempory.net/api/apps/cymsim); default $CYMSIM_CACHE_URL")
137+ p.add_argument("--no-cache", action="store_true", help="ignore $CYMSIM_CACHE_URL")
123138 p.add_argument("--duration", type=float, help="override numerics.duration [s]")
124139 p.add_argument("--out-prefix", help="override output.prefix")
125140 p.add_argument("--linear", action="store_true", help="drop the quadratic strain terms (reference run)")
src/cymsim/simulate.pymodified+62−3View file
@@ -20,6 +20,7 @@ kernel as the listener pressure. See docs/model.md, section "Time integration",
2020 """
2121 from __future__ import annotations
2222
23+import dataclasses
2324 import logging
2425 import math
2526 import time
@@ -231,6 +232,54 @@ class SimulationResult:
231232 return save_cymsim(self, path)
232233
233234
235+def steps_for(n_out: int, substeps: int, steps_per_block: int) -> int:
236+ """Sub-steps of a run of n_out audio samples: the samples, the kernel's look-ahead of W/2
237+ samples, rounded up to whole blocks."""
238+ return math.ceil((n_out + DECIMATION_SPAN // 2) * substeps / steps_per_block) * steps_per_block
239+
240+
241+def _log_until(energy_log: list[EnergyRecord], t_end: float) -> list[EnergyRecord]:
242+ """The reports up to t_end (with a margin of rounding)."""
243+ return [e for e in energy_log if e.time <= t_end * (1 + 1e-12)]
244+
245+
246+def cut_run(prev: SimulationResult, config: SimulationConfig) -> SimulationResult | None:
247+ """The run of ``config`` taken from the longer run ``prev`` (whose configuration differs at
248+ most in CONTINUATION_FIELDS), or None if ``prev`` does not reach the end of ``config``'s run.
249+
250+ This is what ``Simulator(config).run(resume=prev)`` returns in that case, without building the
251+ model: the records are cut to the duration, the samples past it join the state's overhang, and
252+ the energy log stops where an uninterrupted run would have stopped."""
253+ s, num = prev.state, config.numerics
254+ n_out = int(round(prev.fs * num.duration))
255+ n_steps = steps_for(n_out, prev.substeps, num.steps_per_block)
256+ if s is None or s.overhang_front is None or s.n_steps_done < n_steps:
257+ return None
258+ cat = lambda a, b: np.concatenate([a, b])
259+ front, unb = cat(prev.pressure_rayleigh, s.overhang_front), cat(prev.pressure_unbaffled, s.overhang_unbaffled)
260+ over = dict(overhang_front=front[n_out:], overhang_unbaffled=unb[n_out:])
261+ probe = None
262+ if prev.probe_acceleration is not None:
263+ probe = cat(prev.probe_acceleration, s.overhang_probes)
264+ over["overhang_probes"] = probe[n_out:]
265+ acc = prev.acceleration
266+ if acc is not None:
267+ data, scale = cat(acc.data, s.overhang_acceleration_data), cat(acc.scale, s.overhang_acceleration_scale)
268+ over.update(overhang_acceleration_data=data[n_out:], overhang_acceleration_scale=scale[n_out:])
269+ acc = AccelerationRecord(acc.fs, data[:n_out], scale[:n_out], acc.taps)
270+ disp = prev.displacement
271+ if disp is not None:
272+ t, w = cat(disp.time, s.overhang_displacement_time), cat(disp.w, s.overhang_displacement_w)
273+ k_out = int(np.count_nonzero(np.round(t / prev.dt) <= n_out * prev.substeps)) # frame times are steps * dt
274+ over.update(overhang_displacement_time=t[k_out:], overhang_displacement_w=w[k_out:])
275+ disp = DisplacementRecord(disp.rate, t[:k_out], w[:k_out])
276+ return dataclasses.replace(
277+ prev, config=config, pressure_rayleigh=front[:n_out], pressure_unbaffled=unb[:n_out], acceleration=acc,
278+ displacement=disp, complete=True, state=dataclasses.replace(s, **over),
279+ energy_log=_log_until(prev.energy_log, n_steps * prev.dt),
280+ probe_acceleration=None if probe is None else probe[:n_out])
281+
282+
234283 class Simulator:
235284 """Build every part of the model from a SimulationConfig; ``run()`` integrates it."""
236285
@@ -263,8 +312,8 @@ class Simulator:
263312 self.dt = 1.0 / (self.fs * self.substeps)
264313 self.n_out = int(round(self.fs * num.duration))
265314 self.span = DECIMATION_SPAN
266- self.n_blocks = math.ceil((self.n_out + self.span // 2) * self.substeps / num.steps_per_block)
267- self.n_steps = self.n_blocks * num.steps_per_block
315+ self.n_steps = steps_for(self.n_out, self.substeps, num.steps_per_block)
316+ self.n_blocks = self.n_steps // num.steps_per_block
268317 xc, yc = self.mesh.centroid[sh.limiting_element]
269318 log.info("omega_max: element bound %.3e rad/s (limiting element at r = %.3f m, h = %.2f mm), "
270319 "global %.3e rad/s (%d power iterations); %s bound used: dt = %.3e s, "
@@ -513,6 +562,8 @@ class Simulator:
513562 if disp is not None:
514563 disp.extend(resume.displacement.time, resume.displacement.w)
515564 disp.extend(s.overhang_displacement_time, s.overhang_displacement_w)
565+ if n_done >= self.n_steps:
566+ energy_log = _log_until(energy_log, self.n_steps * self.dt)
516567 log.info("continuing the run from t = %.3f s", n_done * self.dt)
517568 probe_now = lambda: np.concatenate(audio_p) if n_probes else None
518569 result_now = lambda n, wall: self._result(state, energy_log, np.concatenate(audio_f), np.concatenate(audio_u),
@@ -592,5 +643,13 @@ class Simulator:
592643 return result
593644
594645
595-def simulate(config: SimulationConfig, resume: SimulationResult | None = None) -> SimulationResult:
646+def simulate(config: SimulationConfig, resume: SimulationResult | None = None,
647+ cache: str | None = None) -> SimulationResult:
648+ """Run ``config`` from rest, from the earlier run ``resume``, or through the cache at the URL
649+ ``cache`` (cache.py)."""
650+ if cache is not None:
651+ if resume is not None:
652+ raise ValueError("give either resume or cache, not both")
653+ from .cache import cached_run
654+ return cached_run(config, cache)
596655 return Simulator(config).run(resume=resume)
tests/test_cache_run.pyadded+173−0View file
@@ -0,0 +1,173 @@
1+"""Reading runs from a cache: a local HTTP server stands in for the artifact-cache app."""
2+import copy
3+import hashlib
4+import logging
5+import threading
6+import urllib.error
7+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
8+
9+import pytest
10+from test_resume import _assert_same_files
11+
12+from cymsim.cache import cache_key, cached_run, fetch
13+from cymsim.cli import main
14+from cymsim.cymsim_file import load_cymsim
15+from cymsim.simulate import Simulator
16+
17+PREFIX = "/api/apps/cymsim/entries/"
18+
19+
20+class FakeCache:
21+ """GET <prefix><key> with ETag revalidation, as the artifact-cache worker serves it."""
22+
23+ def __init__(self):
24+ self.entries, self.requests = {}, []
25+ cache = self
26+
27+ class Handler(BaseHTTPRequestHandler):
28+ def do_GET(self):
29+ key = self.path[len(PREFIX):] if self.path.startswith(PREFIX) else None
30+ data = cache.entries.get(key)
31+ if data is None:
32+ status = 404
33+ self.send_response(404)
34+ self.end_headers()
35+ else:
36+ etag = f'"{hashlib.md5(data).hexdigest()}"'
37+ status = 304 if self.headers.get("If-None-Match") == etag else 200
38+ self.send_response(status)
39+ self.send_header("ETag", etag)
40+ if status == 200:
41+ self.send_header("Content-Length", str(len(data)))
42+ self.end_headers()
43+ if status == 200:
44+ self.wfile.write(data)
45+ cache.requests.append((key, status))
46+
47+ def log_message(self, *args):
48+ pass
49+
50+ self.server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
51+ self.url = f"http://127.0.0.1:{self.server.server_address[1]}{PREFIX[:-len('entries/')]}"
52+ threading.Thread(target=self.server.serve_forever, daemon=True).start()
53+
54+ def put(self, cfg, path):
55+ self.entries[cache_key(cfg)] = path.read_bytes()
56+
57+
58+@pytest.fixture
59+def cache():
60+ c = FakeCache()
61+ yield c
62+ c.server.shutdown()
63+
64+
65+def _config(base, duration):
66+ cfg = copy.deepcopy(base)
67+ cfg.numerics.duration = duration
68+ cfg.output.displacement_rate = 200.0
69+ cfg.output.probes = [(0.1, 0.05), (0.05, -0.12)]
70+ return cfg
71+
72+
73+@pytest.fixture(scope="module")
74+def runs(coarse_config, tmp_path_factory):
75+ """Uninterrupted runs of 0.02 and 0.05 s, saved."""
76+ d = tmp_path_factory.mktemp("runs")
77+ out = {}
78+ for T in (0.02, 0.05):
79+ out[T] = d / f"{T}.cymsim"
80+ Simulator(_config(coarse_config, T)).run(save_path=str(out[T]))
81+ return out
82+
83+
84+def test_without_an_entry_the_run_starts_from_rest(coarse_config, runs, cache, tmp_path):
85+ out = tmp_path / "out.cymsim"
86+ cached_run(_config(coarse_config, 0.05), cache.url, save_path=str(out), directory=tmp_path / "c")
87+ _assert_same_files(out, runs[0.05])
88+ assert cache.requests == [(cache_key(_config(coarse_config, 0.05)), 404)]
89+
90+
91+def test_a_covering_entry_is_cut_without_building_the_model(coarse_config, runs, cache, tmp_path, monkeypatch):
92+ cfg = _config(coarse_config, 0.03)
93+ ref = tmp_path / "ref.cymsim"
94+ Simulator(cfg).run(save_path=str(ref), resume=load_cymsim(runs[0.05]))
95+ cache.put(cfg, runs[0.05])
96+
97+ def no_model(*args, **kwargs):
98+ raise AssertionError("the model was built")
99+
100+ monkeypatch.setattr(Simulator, "__init__", no_model)
101+ out = tmp_path / "out.cymsim"
102+ res = cached_run(cfg, cache.url, save_path=str(out), directory=tmp_path / "c")
103+ assert res.complete and len(res.pressure_rayleigh) == round(44100 * 0.03)
104+ _assert_same_files(out, ref) # including the state, whose overhang has grown
105+ # the energy log stops where an uninterrupted 0.03 s run stops, as on the resume path
106+ cached_log, ref_log = load_cymsim(runs[0.05]).energy_log, load_cymsim(ref).energy_log
107+ assert res.energy_log == ref_log and len(ref_log) < len(cached_log)
108+ assert res.energy_log[-1].time <= 0.03 + 0.02 and cached_log[-1].time > 0.05
109+
110+
111+def test_a_shorter_entry_is_continued(coarse_config, runs, cache, tmp_path):
112+ cfg = _config(coarse_config, 0.05)
113+ cache.put(cfg, runs[0.02])
114+ out = tmp_path / "out.cymsim"
115+ cached_run(cfg, cache.url, save_path=str(out), directory=tmp_path / "c")
116+ _assert_same_files(out, runs[0.05])
117+
118+
119+def test_an_entry_of_another_configuration_is_ignored(coarse_config, runs, cache, tmp_path, caplog):
120+ cfg = _config(coarse_config, 0.02)
121+ other = copy.deepcopy(cfg)
122+ other.hits[0].velocity *= 2
123+ other_path = tmp_path / "other.cymsim"
124+ Simulator(other).run(save_path=str(other_path))
125+ cache.entries[cache_key(cfg)] = other_path.read_bytes()
126+ out = tmp_path / "out.cymsim"
127+ with caplog.at_level(logging.WARNING, logger="cymsim"):
128+ cached_run(cfg, cache.url, save_path=str(out), directory=tmp_path / "c")
129+ assert "different configuration" in caplog.text
130+ _assert_same_files(out, runs[0.02])
131+
132+
133+def test_local_copies(coarse_config, runs, cache, tmp_path):
134+ cfg, d = _config(coarse_config, 0.02), tmp_path / "c"
135+ key = cache_key(cfg)
136+ cache.put(cfg, runs[0.02])
137+ p = fetch(cache.url, key, d)
138+ assert p == d / f"{key}.cymsim" and p.read_bytes() == runs[0.02].read_bytes()
139+ assert fetch(cache.url, key, d) == p
140+ assert [s for _, s in cache.requests] == [200, 304]
141+ cache.entries[key] = runs[0.05].read_bytes() # replaced remotely: downloaded again
142+ assert fetch(cache.url, key, d).read_bytes() == runs[0.05].read_bytes()
143+ dead = "http://127.0.0.1:9/api/apps/cymsim" # unreachable: the local copy serves
144+ assert fetch(dead, key, d) == p
145+ with pytest.raises(urllib.error.URLError):
146+ fetch(dead, "v1/missing", d)
147+ del cache.entries[key] # deleted remotely: the local copy goes too
148+ assert fetch(cache.url, key, d) is None and not p.exists()
149+
150+
151+def test_the_acceleration_record_bypasses_the_cache(coarse_config, cache, tmp_path):
152+ cfg = _config(coarse_config, 0.02)
153+ cfg.output.record_acceleration = True
154+ res = cached_run(cfg, cache.url, directory=tmp_path / "c")
155+ assert res.acceleration is not None and cache.requests == []
156+
157+
158+def test_cli_cache(coarse_config, runs, cache, tmp_path, monkeypatch):
159+ cfg = _config(coarse_config, 0.03)
160+ cfg.output.wav = cfg.output.npz = cfg.output.cascade_plot = False
161+ cfg.output.prefix = str(tmp_path / "x")
162+ ypath = tmp_path / "c.yaml"
163+ cfg.to_yaml(ypath)
164+ cache.put(cfg, runs[0.05])
165+ monkeypatch.setenv("CYMSIM_CACHE_DIR", str(tmp_path / "c"))
166+ out = tmp_path / "out.cymsim"
167+ assert main(["run", str(ypath), "--cache", cache.url, "-o", str(out), "--quiet"]) == 0
168+ assert len(load_cymsim(out).pressure_rayleigh) == round(44100 * 0.03)
169+ monkeypatch.setenv("CYMSIM_CACHE_URL", cache.url)
170+ assert main(["run", str(ypath), "-o", str(out), "--quiet"]) == 0
171+ assert main(["run", str(ypath), "--no-cache", "-o", str(out), "--quiet"]) == 0
172+ assert [s for _, s in cache.requests] == [200, 304]
173+ assert main(["run", str(ypath), "--cache", cache.url, "--resume", str(out), "--quiet"]) == 2