Files
SystemSimulationApp/tests/test_simulation_property_cache.py
T

215 lines
7.2 KiB
Python

from __future__ import annotations
import asyncio
from concurrent.futures import ThreadPoolExecutor
from math import nextafter
import os
from pathlib import Path
import subprocess
import sys
import textwrap
from threading import Barrier
import unittest
from app.simulation.components.amesim.media.mediums import (
AmesimHeliumPengRobinsonMedium,
)
from app.simulation.property_cache import (
cache_property_calculation,
current_property_cache,
property_cache_run,
)
PROJECT_ROOT = Path(__file__).resolve().parent.parent
class SimulationPropertyCacheTests(unittest.TestCase):
def test_reuses_only_exact_inputs(self) -> None:
medium = AmesimHeliumPengRobinsonMedium()
pressure = 15.3e6
temperature = 293.15
with property_cache_run() as cache:
assert cache is not None
first = medium.density(pressure, temperature)
repeated = medium.density(pressure, temperature)
changed = medium.density(nextafter(pressure, float("inf")), temperature)
info = cache.info()
self.assertEqual(repeated, first)
self.assertNotEqual(changed, first)
self.assertEqual(info.hits, 1)
self.assertEqual(info.misses, 2)
self.assertEqual(info.current_entries, 2)
def test_failed_calculations_are_not_cached(self) -> None:
class FailingProperty:
def __init__(self) -> None:
self.calls = 0
@cache_property_calculation("failure")
def calculate(self, value: float) -> float:
self.calls += 1
raise ValueError(f"invalid {value}")
owner = FailingProperty()
with property_cache_run() as cache:
assert cache is not None
for _ in range(2):
with self.assertRaisesRegex(ValueError, "invalid"):
owner.calculate(1.0)
info = cache.info()
self.assertEqual(owner.calls, 2)
self.assertEqual(info.hits, 0)
self.assertEqual(info.misses, 2)
self.assertEqual(info.current_entries, 0)
def test_lru_capacity_is_bounded_and_oldest_entry_is_evicted(self) -> None:
class CachedProperty:
def __init__(self) -> None:
self.calls = 0
@cache_property_calculation("bounded")
def calculate(self, value: int) -> int:
self.calls += 1
return value * 10
owner = CachedProperty()
with property_cache_run(max_entries=2) as cache:
assert cache is not None
owner.calculate(1)
owner.calculate(2)
owner.calculate(1)
owner.calculate(3)
second = owner.calculate(2)
info = cache.info()
self.assertEqual(second, 20)
self.assertEqual(owner.calls, 4)
self.assertEqual(info.current_entries, 2)
self.assertEqual(info.evictions, 2)
def test_each_run_gets_an_independent_cache_and_releases_context(self) -> None:
medium = AmesimHeliumPengRobinsonMedium()
with property_cache_run() as first_cache:
assert first_cache is not None
medium.density(100_000.0, 300.0)
medium.density(100_000.0, 300.0)
first_info = first_cache.info()
self.assertIsNone(current_property_cache())
with property_cache_run() as second_cache:
assert second_cache is not None
medium.density(100_000.0, 300.0)
second_info = second_cache.info()
self.assertIsNot(first_cache, second_cache)
self.assertEqual(first_info.hits, 1)
self.assertEqual(second_info.hits, 0)
self.assertEqual(second_info.misses, 1)
self.assertIsNone(current_property_cache())
def test_async_tasks_do_not_share_run_local_caches(self) -> None:
medium = AmesimHeliumPengRobinsonMedium()
async def exercise(pressure: float) -> tuple[int, int, int]:
with property_cache_run() as cache:
assert cache is not None
await asyncio.sleep(0)
medium.density(pressure, 300.0)
medium.density(pressure, 300.0)
info = cache.info()
return id(cache), info.hits, info.misses
async def run_both() -> list[tuple[int, int, int]]:
return list(
await asyncio.gather(
exercise(100_000.0),
exercise(200_000.0),
)
)
results = asyncio.run(run_both())
self.assertNotEqual(results[0][0], results[1][0])
self.assertEqual(results[0][1:], (1, 1))
self.assertEqual(results[1][1:], (1, 1))
def test_worker_threads_do_not_share_run_local_caches(self) -> None:
medium = AmesimHeliumPengRobinsonMedium()
barrier = Barrier(2)
def exercise(pressure: float) -> tuple[int, int, int]:
with property_cache_run() as cache:
assert cache is not None
barrier.wait(timeout=5.0)
medium.density(pressure, 300.0)
medium.density(pressure, 300.0)
info = cache.info()
return id(cache), info.hits, info.misses
with ThreadPoolExecutor(max_workers=2) as executor:
results = list(
executor.map(
exercise,
(100_000.0, 200_000.0),
)
)
self.assertNotEqual(results[0][0], results[1][0])
self.assertEqual(results[0][1:], (1, 1))
self.assertEqual(results[1][1:], (1, 1))
def test_cache_on_and_off_produce_identical_helium_results(self) -> None:
script = textwrap.dedent(
"""
import hashlib
import json
from app.main import build_reactflow_system_xml, run_system_xml_simulation
from tests.test_amesim_pnvo001_signal_xml import (
high_pressure_helium_step_project,
)
result = run_system_xml_simulation(
build_reactflow_system_xml(high_pressure_helium_step_project())
)
assert result["success"], result["message"]
payload = json.dumps(
{
"status": result["status"],
"series": result["series"],
"final": result["final"],
},
sort_keys=True,
separators=(",", ":"),
allow_nan=False,
).encode("utf-8")
print(hashlib.sha256(payload).hexdigest())
"""
)
hashes: list[str] = []
for enabled in ("on", "off"):
environment = os.environ.copy()
environment["SIMULATIONAPP_PROFILE"] = "off"
environment["SIMULATIONAPP_PROPERTY_CACHE"] = enabled
completed = subprocess.run(
[sys.executable, "-c", script],
cwd=PROJECT_ROOT,
env=environment,
check=True,
capture_output=True,
text=True,
timeout=30,
)
hashes.append(completed.stdout.strip())
self.assertEqual(hashes[0], hashes[1])
if __name__ == "__main__":
unittest.main()