215 lines
7.2 KiB
Python
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()
|