Files
SystemSimulationApp/app/simulation/native_codegen/runner.py
T

129 lines
6.7 KiB
Python

"""Isolated native numerical execution; Python only handles process I/O."""
from __future__ import annotations
import json
import os
from pathlib import Path
import queue
import subprocess
import tempfile
import threading
import time
from app.simulation.config import SolveIVPConfig
from app.simulation.results import GenericSimulationResult
from .build import NativeBuild, build_native
from .compiler import NativeCapabilityError, compile_native_program
def execute_native(build: NativeBuild, config: SolveIVPConfig, sample_step: float, *,
run_dir: Path, record_samples=True, cancel_check=None,
progress_callback=None, activity_tracker=None, timeout=300.0) -> dict:
if config.method not in ("RK45", "BDF"):
raise NativeCapabilityError(f"Native v1 does not support method {config.method}.")
if not isinstance(config.atol, (int, float)) or config.atol != 1e-8 or config.first_step is not None:
raise NativeCapabilityError("Native v1 uses the existing default gas/mechanical absolute tolerances and automatic initial step.")
run_dir.mkdir(parents=True, exist_ok=True)
output = run_dir / "result.json"
cancel_path = run_dir / "cancel.request"
if output.exists() or cancel_path.exists():
raise ValueError("Native execution requires a fresh run directory.")
command = [str(build.executable), "--method", config.method,
"--start", str(config.t_start), "--stop", str(config.t_stop),
"--sample-step", str(sample_step), "--max-step", str(config.max_step),
"--rtol", str(config.rtol), "--timeout", str(timeout),
"--cancel-file", str(cancel_path.resolve()), "--output", str(output.resolve())]
if not record_samples:
command.append("--solve-only")
creationflags = subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0
started = time.perf_counter()
process = subprocess.Popen(command, cwd=build.executable.parent, stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE,
text=True, encoding="utf-8", errors="replace", creationflags=creationflags)
messages: queue.Queue[str] = queue.Queue()
def read_stderr():
assert process.stderr is not None
for line in process.stderr:
messages.put(line)
reader = threading.Thread(target=read_stderr, daemon=True)
reader.start()
if activity_tracker is not None:
activity_tracker.start_integration(config.t_start)
cancelled_at = None
last_time = config.t_start
try:
with (run_dir / "worker.log").open("w", encoding="utf-8") as log:
while process.poll() is None or not messages.empty() or reader.is_alive():
now = time.perf_counter()
if cancel_check is not None and cancel_check() and cancelled_at is None:
cancel_path.write_text("cancel\n", encoding="ascii")
cancelled_at = now
if now-started > timeout+5 or (cancelled_at is not None and now-cancelled_at > 5):
process.kill()
process.wait(timeout=5)
raise RuntimeError("Native worker was terminated after failing to return within its time limit.")
try:
line = messages.get(timeout=0.05)
except queue.Empty:
continue
log.write(line)
try:
event = json.loads(line)
except json.JSONDecodeError:
continue
if event.get("phase") == "integrating":
last_time = max(last_time, min(config.t_stop, float(event["time"])))
if progress_callback:
progress_callback((last_time-config.t_start)/(config.t_stop-config.t_start), "integrating")
if activity_tracker is not None:
activity_tracker.record_native_progress(last_time, int(event["nfev"]), int(event["acceptedSteps"]))
finally:
if process.poll() is None:
process.kill()
process.wait(timeout=5)
reader.join(timeout=2)
if process.stderr is not None:
process.stderr.close()
if not output.is_file():
raise RuntimeError(f"Native worker exited with code {process.returncode} without results; see {run_dir / 'worker.log'}.")
payload = json.loads(output.read_text(encoding="utf-8"))
if process.returncode not in (0, 2):
raise RuntimeError(f"Native worker failed with exit code {process.returncode}.")
payload["processWallSeconds"] = time.perf_counter()-started
payload["buildKey"] = build.manifest["buildKey"]
payload["cacheHit"] = build.cache_hit
payload["buildSeconds"] = build.seconds
if activity_tracker is not None:
activity_tracker.record_native_progress(payload["simulatedUntil"], payload["nfev"], payload["acceptedSteps"])
return payload
def simulate_native(network, config, *, sample_step, progress_callback=None,
cancel_check=None, activity_tracker=None):
if config.method not in ("RK45", "BDF"):
raise NativeCapabilityError(f"Native v1 does not support method {config.method}.")
if progress_callback:
progress_callback(0.0, "initializing")
program = compile_native_program(network)
build = build_native(program)
with tempfile.TemporaryDirectory(prefix="native-simulation-") as directory:
data = execute_native(build, config, sample_step, run_dir=Path(directory),
cancel_check=cancel_check, progress_callback=progress_callback,
activity_tracker=activity_tracker)
totals = {
"nfev": data["nfev"], "njev": data["njev"], "nlu": data["nlu"],
"acceptedStepCount": data["acceptedSteps"], "rejectedStepCount": data["rejectedSteps"],
"stateTransitionCount": data["stateTransitions"], "solverStartCount": data["solverStarts"],
}
if progress_callback:
fraction = (data["simulatedUntil"]-config.t_start)/(config.t_stop-config.t_start)
progress_callback(fraction, "complete" if data["success"] else data["status"])
return GenericSimulationResult(
success=data["success"], status=data["status"], message=data["message"],
simulated_until=data["simulatedUntil"], requested_stop_time=config.t_stop,
variables=program.variables, series=data["series"], final=data["final"],
diagnostics={"backend": "native-c", "native": {k: v for k, v in data.items()
if k not in ("series", "final", "finalState")}, "integration": {"method": config.method, "totals": totals},
"stateCount": len(program.state_keys), "sampleCount": len(data["series"]["time"])},
)