129 lines
6.7 KiB
Python
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"])},
|
|
)
|