Skip to content

fix: carry pre-bolus step size across dose ReInit (fixes #25) - #26

Merged
jeliason merged 1 commit into
mainfrom
fix/solver-bolus-init-step
May 12, 2026
Merged

jeliason merged 1 commit into
mainfrom
fix/solver-bolus-init-step

Conversation

@jeliason

Copy link
Copy Markdown
Member

Summary

Fixes #25 — CVODE collapse at the day-7 nivolumab bolus in the gvax_nivo_neoadjuvant_zheng2022 scenario.

Root cause was not stiffness or a missing ReInit (both were red herrings in the original triage). Per-step CVODE diagnostics show the post-bolus step had zero error-test failures and zero Newton convergence failures — CVODE succeeded, but with h = 1.4e-9 s at solver time t ≈ 1.6e8 s, which is below the double-precision ULP. The CV_ONE_STEP loop's "did not advance" guard then aborts the run, even though CVODE was about to grow h to 8e-6 s on the next call.

The bad h came from CVODE's initial-step estimator (CVHin), which picks h₀ from ||f(t,y)||_WRMS at the post-bolus state. The nivo perturbation to V_C.aPD1 couples into the fast PD1/aPD1 synapse binding cross-section, the norm explodes, and h₀ → sub-ULP.

Fix

  • setupSamplingRun gains an optional h0_hint that bypasses CVHin via CVodeSetInitStep.
  • qsp_sim_main.cpp captures pre-event h_cur and passes min(h_cur, 1.0 s) as the hint. The 1 s cap is conservative enough that BDF converges without Newton retries on most discontinuities; still well above ULP at any realistic solver time.

This mirrors how SimBiology carries h-hints across events, which is why MATLAB never tripped this.

Also included

  • CVODEBase::getStepStats() — per-step CVODE diagnostics (order, h_last, h_cur, error-test fails, Newton conv fails, Jac/RHS evals). Opt-in via QSP_TRACE_CVODE env var in qsp_sim. This was the diagnostic that distinguished "CVODE struggling" from "CVODE took a sub-ULP step and the outer loop bailed."
  • output_times_csv plumbed through run_matlab_trajectories and export_matlab_trajectories.m. When provided, MATLAB pins its grid to the C++ grid — eliminating spurious "post-bolus value at pre-bolus timestamp" parity failures from linear interpolation across a discontinuity. Pattern lifted from paper/benchmark/.

Verification

Against pdac-build's tests/test_cpp_matlab_parity.py::test_sbi_scenario_parity with output_times_csv wired up:

  • gvax_nivo — PASS (was crashing at t=7d)
  • gvax_neoadjuvant — PASS
  • baseline_no_treatment / clinical_progression — pre-existing MATLAB bug in schedule_dosing for drugless scenarios; unrelated.

Follow-ups (not in this PR)

  • pdac-build needs a small companion change to thread output_times_csv through its parity test.
  • scripts/evolve_to_diagnosis.m still uses the legacy spqsp_resource() lookup for healthy_state.yaml — part of issue Rename CancerVCT namespace and scrub legacy SPQSP_PDAC references from runtime #19's SPQSP_PDAC scrub.
  • schedule_dosing bug for drugless scenarios.
  • H_PD1_APC shows a ~8.7% transient ~10 min post-bolus on gvax_nivo; still inside the documented 5–20% envelope but worth a closer look.

CVODE's initial-step estimator (CVHin) picks h₀ from ||f(t,y)||_WRMS at
the post-bolus state. When a bolus jolts a fast-binding subsystem
(e.g. nivolumab → V_C.aPD1, which couples into the PD1/aPD1 synapse
binding cross-section), the norm is huge and CVHin returns a sub-ULP h₀.
At solver times O(1e8) s, the resulting step doesn't change t in double
precision and the CV_ONE_STEP loop's "did not advance" guard aborts —
even though the corrector succeeded with zero error-test and Newton
convergence failures.

setupSamplingRun now accepts an optional h0_hint that bypasses CVHin
via CVodeSetInitStep. The sampling loop captures pre-event h_cur and
passes std::min(h_cur, 1.0 s) as the hint — well above ULP, conservative
enough that BDF converges without retries on most discontinuities. This
mirrors how SimBiology carries h-hints across events.

Also adds:
- CVODEBase::getStepStats() exposing per-step CVODE counters
  (order, h_last, h_cur, error-test fails, Newton conv fails, Jac/RHS
  evals). Opt-in via QSP_TRACE_CVODE env var in qsp_sim_main for
  diagnosing stiff-step issues without rebuilding.
- Optional output_times_csv plumbing through parity infra
  (run_matlab_trajectories + export_matlab_trajectories.m). Pinning
  MATLAB's grid to the C++ grid eliminates spurious "post-bolus value
  at pre-bolus timestamp" failures from linear interpolation across a
  dose discontinuity. Pattern lifted from paper/benchmark/.

Verified against pdac-build gvax_nivo_neoadjuvant_zheng2022 scenario:
the day-7 nivo bolus no longer collapses h to 1e-9 s, and grid-aligned
MATLAB/C++ parity passes at rtol=5e-2, atol=1e-6 over 30 d.
@jeliason
jeliason merged commit d660a6e into main May 12, 2026
2 checks passed
@jeliason
jeliason deleted the fix/solver-bolus-init-step branch May 12, 2026 16:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

CVODE stiff failure at bolus event in gvax_nivo scenario (MATLAB SimBiology runs same model fine)

1 participant