"""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 uses generated per-state absolute tolerances and automatic initial step; custom config.atol/first_step are not supported.") 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, "rtol": config.rtol, "totals": totals}, "stateCount": len(program.state_keys), "sampleCount": len(data["series"]["time"])}, )