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()