Files
SystemSimulationApp/tests/test_native_cache_platform.py

200 lines
9.1 KiB
Python

"""Real native cache integration on both Windows and Linux.
The fixture uses the configured compiler and SUNDIALS installation directly.
CI sets SIMULATION_NATIVE_REQUIRE_TOOLCHAIN=1 so missing native dependencies
fail this suite instead of silently skipping platform acceptance.
"""
from __future__ import annotations
from dataclasses import replace
from hashlib import sha256
import json
import math
import os
from pathlib import Path
import subprocess
import tempfile
import threading
import unittest
from unittest.mock import patch
import xml.etree.ElementTree as ET
from app.main import compile_system_xml_network
from app.simulation.backends import simulation_config
from app.simulation.config import SolverActivityTracker
from app.simulation.native_codegen import build as native_build
from app.simulation.native_codegen.cache_storage import prune_cache
from app.simulation.native_codegen.compiler import compile_native_program
from app.simulation.native_codegen.runner import execute_native
from app.system_xml import validate_system_xml_document
ROOT = Path(__file__).resolve().parents[1]
FIXTURE = ROOT / "tests/fixtures/native-skill-test.xml"
class NativeCachePlatformTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
try:
cls.compiler, cls.sundials, cls.compiler_version = native_build.toolchain()
except (OSError, RuntimeError, subprocess.SubprocessError) as exc:
message = f"Native cache platform test requires a working configured toolchain: {exc}"
if os.environ.get("SIMULATION_NATIVE_REQUIRE_TOOLCHAIN") == "1":
raise RuntimeError(message) from exc
raise unittest.SkipTest(message) from exc
def setUp(self):
self.temporary = tempfile.TemporaryDirectory(prefix="native-platform-")
self.addCleanup(self.temporary.cleanup)
# Exercise argument handling with spaces without assuming shell quoting.
self.root = Path(self.temporary.name) / "cache integration"
self.root.mkdir()
self.cache = self.root / "cache"
self.real_run = subprocess.run
self._builds = []
self.addCleanup(self._close_builds)
self.document = self._document(FIXTURE.read_bytes())
self.program = compile_native_program(compile_system_xml_network(self.document))
self.config = replace(simulation_config(self.document.simulation),
method="RK45", t_stop=0.1)
def _close_builds(self):
for build in reversed(self._builds):
build.close()
def _document(self, xml):
report = validate_system_xml_document(xml)
self.assertTrue(report.valid, report.as_dict())
self.assertIsNotNone(report.document)
return report.document
def _build(self, program):
commands = []
lock = threading.Lock()
def record(command, *args, **kwargs):
with lock:
commands.append(tuple(map(str, command)))
return self.real_run(command, *args, **kwargs)
with patch.object(native_build.subprocess, "run", side_effect=record):
build = native_build.build_native(program, cache_dir=self.cache)
self._builds.append(build)
return build, commands
@staticmethod
def _compile_count(commands):
return sum("-c" in command for command in commands)
@staticmethod
def _link_count(commands):
return sum("-o" in command and "-c" not in command and "-E" not in command
for command in commands)
def _execute(self, build, name, **kwargs):
payload = execute_native(build, self.config, 0.02,
run_dir=self.root / name, timeout=30, **kwargs)
self.assertTrue(payload["success"], payload.get("message"))
self.assertEqual(payload["simulatedUntil"], self.config.t_stop)
self.assertEqual(payload["series"]["time"][-1], self.config.t_stop)
self.assertEqual(set(payload["series"]), {"time", *(v.key for v in self.program.variables)})
self.assertTrue(all(math.isfinite(value)
for series in payload["series"].values() for value in series))
return payload
def _assert_windows_package(self, build):
if os.name != "nt":
return
self.assertEqual(build.executable.name, "model.exe")
names = [f"sundials_{name}.dll" for name in native_build.LIBRARIES]
if (self.sundials / "bin/vcruntime140.dll").is_file():
names.append("vcruntime140.dll")
for name in names:
with self.subTest(dll=name):
original = self.sundials / "bin" / name
packaged = build.executable.parent / name
self.assertTrue(packaged.is_file())
digest = sha256(packaged.read_bytes()).hexdigest()
self.assertEqual(digest, sha256(original.read_bytes()).hexdigest())
self.assertEqual(digest, build.manifest["artifacts"][name])
self.assertEqual(digest, build.manifest["buildIdentity"]["dependencies"][name])
# The EXE must load its copied DLLs without the compiler/Conda directories.
system32 = Path(os.environ.get("SystemRoot", r"C:\Windows")) / "System32"
completed = self.real_run(
[str(build.executable), "--init"], cwd=self.root,
env={**os.environ, "PATH": str(system32)},
capture_output=True, text=True, check=True, timeout=15,
)
self.assertEqual(len(json.loads(completed.stdout)), len(self.program.state_keys))
def test_real_build_execution_reuse_and_in_use_eviction(self):
self.assertFalse(self.cache.exists())
cold, cold_commands = self._build(self.program)
self.assertFalse(cold.cache_hit)
self.assertGreater(self._compile_count(cold_commands), 1)
self.assertEqual(self._link_count(cold_commands), 1)
self.assertEqual((cold.executable.parent / "model.c").read_bytes(), self.program.source.encode())
self.assertEqual((cold.executable.parent / "model.h").read_bytes(), self.program.header.encode())
self._assert_windows_package(cold)
cold_result = self._execute(cold, "cold run")
warm, warm_commands = self._build(self.program)
self.assertTrue(warm.cache_hit)
self.assertEqual(cold.manifest["buildKey"], warm.manifest["buildKey"])
self.assertEqual(self._compile_count(warm_commands), 0)
self.assertEqual(self._link_count(warm_commands), 0)
self.assertEqual(warm.details["objectCompilations"], 0)
self.assertEqual(warm.details["linkSeconds"], 0)
warm_result = self._execute(warm, "warm run")
self.assertEqual(cold_result["series"], warm_result["series"])
self.assertEqual(cold_result["finalState"], warm_result["finalState"])
xml = ET.fromstring(FIXTURE.read_bytes())
pressure = xml.find("./Components/Component[@id='amesim_pnch023_1']/Parameter[@name='p0']")
self.assertIsNotNone(pressure)
pressure.set("value", "16000000")
modified = self._document(ET.tostring(xml, encoding="utf-8"))
changed_program = compile_native_program(compile_system_xml_network(modified))
self.assertEqual(changed_program.header, self.program.header)
changed, changed_commands = self._build(changed_program)
self.assertFalse(changed.cache_hit)
self.assertNotEqual(changed.manifest["buildKey"], cold.manifest["buildKey"])
self.assertEqual(self._compile_count(changed_commands), 1)
self.assertEqual(self._link_count(changed_commands), 1)
self.assertEqual(changed.details["objectCompilations"], 1)
self.assertGreater(changed.details["objectCacheHits"], 0)
self._assert_windows_package(changed)
test = self
sweeps = []
class PruningTracker(SolverActivityTracker):
def start_integration(self, time):
super().start_integration(time)
# execute_native calls this after starting the real C child,
# before reading its result. Both model entries are still in use.
report = prune_cache(test.cache, model_limit_bytes=0)
sweeps.append(report)
test.assertGreaterEqual(report["models"]["skippedInUse"], 2)
test.assertTrue(cold.executable.is_file())
test.assertTrue(changed.executable.is_file())
changed_result = self._execute(changed, "changed run", activity_tracker=PruningTracker())
self.assertEqual(len(sweeps), 1)
column = "amesim_pnch023_1.p"
self.assertNotEqual(changed_result["series"][column][0], cold_result["series"][column][0])
self.assertEqual(changed_result["buildKey"], changed.manifest["buildKey"])
# Once use ends, a zero budget may evict the older model while retaining
# the final oversized entry. Release every shared handle explicitly.
self._close_builds()
report = prune_cache(self.cache, model_limit_bytes=0)
self.assertEqual(report["models"]["removedEntries"], 1)
self.assertFalse(cold.executable.exists())
self.assertTrue(changed.executable.is_file())
if __name__ == "__main__":
unittest.main()