From 458efd19eb57c0aa4649d2738be323974eb01ea9 Mon Sep 17 00:00:00 2001 From: yeus Date: Tue, 4 Aug 2026 10:01:01 -0700 Subject: [PATCH] fix(rk45): support zero-state batch simulations Route zero-state batch simulations through the existing NoStateSession runtime instead of returning EmptySystem. Collect scheduled output samples and preserve termination details in the resulting simulation output. Signed-off-by: yeus --- crates/rumoca-solver-rk45/src/lib.rs | 12 +++---- crates/rumoca-solver-rk45/src/no_state.rs | 44 ++++++++++++++++++++++- crates/rumoca-solver-rk45/src/tests.rs | 20 +++++++++++ 3 files changed, 68 insertions(+), 8 deletions(-) diff --git a/crates/rumoca-solver-rk45/src/lib.rs b/crates/rumoca-solver-rk45/src/lib.rs index 656a12a88..2b3c1399a 100644 --- a/crates/rumoca-solver-rk45/src/lib.rs +++ b/crates/rumoca-solver-rk45/src/lib.rs @@ -24,7 +24,7 @@ mod no_state; mod reset; mod trace; -use no_state::NoStateSession; +use no_state::{NoStateSession, simulate_no_state}; use reset::Rk45ResetSnapshot; use trace::{ record_derivative_eval_trace, record_root_eval_trace, reset_rk_eval_trace, @@ -38,9 +38,6 @@ const ALGEBRAIC_REFRESH_TOL: f64 = 1.0e-10; #[derive(Debug, thiserror::Error)] pub enum SimError { - #[error("empty system: no state equations to simulate")] - EmptySystem, - #[error("rk45 backend does not support solver mode {requested:?}")] UnsupportedSolverMode { requested: SimSolverMode }, @@ -484,6 +481,10 @@ pub fn simulate(model: &solve::SolveModel, opts: &SimOptions) -> Result return Err(SimError::UnsupportedSolverMode { requested }), } + if model.state_scalar_count() == 0 { + return simulate_no_state(model, opts); + } + validate_explicit_solve_model(model)?; let model = SolveRuntime::new(model)?; let sample_dt = default_output_dt(opts); @@ -532,9 +533,6 @@ pub fn simulate(model: &solve::SolveModel, opts: &SimOptions) -> Result Result<(), SimError> { let layout = &model.problem.solve_layout; - if layout.state_scalar_count == 0 { - return Err(SimError::EmptySystem); - } if model.initial_y.len() != model.solver_scalar_count() { return Err(SimError::SolveIr(format!( "initial vector length {} does not match solver layout {}", diff --git a/crates/rumoca-solver-rk45/src/no_state.rs b/crates/rumoca-solver-rk45/src/no_state.rs index 246f2d9d5..6ccf3900b 100644 --- a/crates/rumoca-solver-rk45/src/no_state.rs +++ b/crates/rumoca-solver-rk45/src/no_state.rs @@ -13,11 +13,53 @@ use rumoca_solver::{ timeline::{event_left_limit_time, sample_time_match_with_tol}, }; -use crate::{SessionState, SimError}; +use crate::{SessionState, SimError, checked_vec_with_capacity, default_output_dt}; const NO_STATE_EVENT_UPDATE_MAX_ITERS: usize = 256; const ROOT_BISECTION_ITERS: usize = 64; +pub(crate) fn simulate_no_state( + model: &solve::SolveModel, + opts: &SimOptions, +) -> Result { + let sample_dt = default_output_dt(opts); + let sample_times = + rumoca_solver::timeline::build_output_times(opts.t_start, opts.t_end, sample_dt); + let mut session = NoStateSession::new(model, opts.clone())?; + let mut times = checked_vec_with_capacity(sample_times.len(), "RK45 no-state output times")?; + let mut data = + checked_vec_with_capacity(model.visible_names.len(), "RK45 no-state output series")?; + for _ in &model.visible_names { + data.push(checked_vec_with_capacity( + sample_times.len(), + "RK45 no-state output samples", + )?); + } + + for sample_time in sample_times { + session.advance_to(sample_time)?; + let values = session.runtime.runtime.visible_values( + &session.runtime.current_y, + &session.runtime.params, + session.runtime.current_t, + )?; + for (series, value) in data.iter_mut().zip(values) { + series.push(value); + } + times.push(session.runtime.current_t); + if session.runtime.termination.is_some() { + break; + } + } + + Ok(rumoca_solver::build_sim_result_from_solve_model( + model, + times, + data, + session.runtime.termination, + )) +} + pub(crate) struct NoStateSession { model: solve::SolveModel, opts: SimOptions, diff --git a/crates/rumoca-solver-rk45/src/tests.rs b/crates/rumoca-solver-rk45/src/tests.rs index f9c394c39..ece030f15 100644 --- a/crates/rumoca-solver-rk45/src/tests.rs +++ b/crates/rumoca-solver-rk45/src/tests.rs @@ -882,6 +882,26 @@ fn rk45_session_runs_no_state_discrete_controller() { assert_eq!(session.get("y").expect("read y"), Some(7.5)); } +#[test] +fn rk45_batch_runs_no_state_discrete_controller() { + let model = no_state_input_accumulator_model(); + let result = simulate( + &model, + &SimOptions { + t_end: 0.05, + dt: Some(0.01), + solver_mode: SimSolverMode::RkLike, + ..Default::default() + }, + ) + .expect("no-state batch simulation should succeed"); + + assert_eq!(result.times.len(), 6); + assert_eq!(result.names, ["y"]); + assert_eq!(result.data, [vec![0.0; 6]]); + assert_eq!(result.n_states, 0); +} + #[test] fn rk45_session_uses_adaptive_event_integration_for_stiff_contact() { let model = stiff_contact_model();