use pharmsol::prelude::models::{one_compartment, one_compartment_with_absorption};
use pharmsol::*;
const REL_TOL: f64 = 1e-2; const ABS_TOL: f64 = 1e-6;
fn assert_ode_matches_analytical(
label: &str,
analytical: &equation::Analytical,
ode: &equation::ODE,
subject: &Subject,
params: &[f64],
) {
let params_vec: Vec<f64> = params.to_vec();
let analytical_predictions = analytical
.estimate_predictions(subject, ¶ms_vec)
.expect("analytical predictions should succeed");
let ode_predictions = ode
.estimate_predictions(subject, ¶ms_vec)
.expect("ode predictions should succeed");
let expected = analytical_predictions.flat_predictions();
let actual = ode_predictions.flat_predictions();
assert_eq!(
expected.len(),
actual.len(),
"{}: prediction vector length mismatch (analytical={}, ode={})",
label,
expected.len(),
actual.len()
);
for (idx, (&reference, &candidate)) in expected.iter().zip(actual.iter()).enumerate() {
let abs_err = (reference - candidate).abs();
let rel_err = if reference.abs() > ABS_TOL {
abs_err / reference.abs()
} else {
abs_err
};
assert!(
abs_err <= ABS_TOL || rel_err <= REL_TOL,
"{}: prediction {} differs significantly\n analytical = {:.10}\n ode = {:.10}\n abs_err = {:.2e}\n rel_err = {:.2e}",
label,
idx,
reference,
candidate,
abs_err,
rel_err
);
}
}
#[test]
fn single_iv_bolus_matches_analytical() {
let subject = Subject::builder("single_bolus")
.bolus(0.0, 100.0, 0)
.observation(1.0, 0.0, 0)
.observation(2.0, 0.0, 0)
.observation(4.0, 0.0, 0)
.observation(8.0, 0.0, 0)
.observation(12.0, 0.0, 0)
.observation(24.0, 0.0, 0)
.build();
let analytical = equation::Analytical::new(
one_compartment,
|_p, _t, _cov| {},
|_p, _t, _cov| lag! {},
|_p, _t, _cov| fa! {},
|_p, _t, _cov, _x| {},
|x, p, _t, _cov, y| {
fetch_params!(p, _ke, v);
y[0] = x[0] / v;
},
)
.with_nstates(1)
.with_nout(1);
let ode = equation::ODE::new(
|x, p, _t, dx, b, _rateiv, _cov| {
fetch_params!(p, ke, _v);
dx[0] = -ke * x[0] + b[0];
},
|_p, _t, _cov| lag! {},
|_p, _t, _cov| fa! {},
|_p, _t, _cov, _x| {},
|x, p, _t, _cov, y| {
fetch_params!(p, _ke, v);
y[0] = x[0] / v;
},
)
.with_nstates(1)
.with_nout(1);
assert_ode_matches_analytical("single_iv_bolus", &analytical, &ode, &subject, &[0.1, 50.0]);
}
#[test]
fn multiple_iv_boluses_match_analytical() {
let subject = Subject::builder("multiple_boluses")
.bolus(0.0, 100.0, 0)
.observation(1.0, 0.0, 0)
.observation(2.0, 0.0, 0)
.bolus(4.0, 50.0, 0) .observation(4.0, 0.0, 0)
.observation(5.0, 0.0, 0)
.observation(6.0, 0.0, 0)
.bolus(8.0, 75.0, 0) .observation(8.0, 0.0, 0)
.observation(10.0, 0.0, 0)
.observation(12.0, 0.0, 0)
.observation(24.0, 0.0, 0)
.build();
let analytical = equation::Analytical::new(
one_compartment,
|_p, _t, _cov| {},
|_p, _t, _cov| lag! {},
|_p, _t, _cov| fa! {},
|_p, _t, _cov, _x| {},
|x, p, _t, _cov, y| {
fetch_params!(p, _ke, v);
y[0] = x[0] / v;
},
)
.with_nstates(1)
.with_nout(1);
let ode = equation::ODE::new(
|x, p, _t, dx, b, _rateiv, _cov| {
fetch_params!(p, ke, _v);
dx[0] = -ke * x[0] + b[0];
},
|_p, _t, _cov| lag! {},
|_p, _t, _cov| fa! {},
|_p, _t, _cov, _x| {},
|x, p, _t, _cov, y| {
fetch_params!(p, _ke, v);
y[0] = x[0] / v;
},
)
.with_nstates(1)
.with_nout(1);
assert_ode_matches_analytical(
"multiple_iv_boluses",
&analytical,
&ode,
&subject,
&[0.1, 50.0],
);
}
#[test]
fn oral_bolus_with_absorption_matches_analytical() {
let subject = Subject::builder("oral_bolus")
.bolus(0.0, 100.0, 0) .observation(0.5, 0.0, 0)
.observation(1.0, 0.0, 0)
.observation(2.0, 0.0, 0)
.observation(4.0, 0.0, 0)
.observation(8.0, 0.0, 0)
.observation(12.0, 0.0, 0)
.observation(24.0, 0.0, 0)
.build();
let analytical = equation::Analytical::new(
one_compartment_with_absorption,
|_p, _t, _cov| {},
|_p, _t, _cov| lag! {},
|_p, _t, _cov| fa! {},
|_p, _t, _cov, _x| {},
|x, p, _t, _cov, y| {
fetch_params!(p, _ka, _ke, v);
y[0] = x[1] / v; },
)
.with_nstates(2)
.with_nout(1);
let ode = equation::ODE::new(
|x, p, _t, dx, b, _rateiv, _cov| {
fetch_params!(p, ka, ke, _v);
dx[0] = -ka * x[0] + b[0]; dx[1] = ka * x[0] - ke * x[1]; },
|_p, _t, _cov| lag! {},
|_p, _t, _cov| fa! {},
|_p, _t, _cov, _x| {},
|x, p, _t, _cov, y| {
fetch_params!(p, _ka, _ke, v);
y[0] = x[1] / v;
},
)
.with_nstates(2)
.with_nout(1);
assert_ode_matches_analytical(
"oral_bolus_absorption",
&analytical,
&ode,
&subject,
&[1.0, 0.1, 50.0],
);
}
#[test]
fn multiple_oral_doses_match_analytical() {
let subject = Subject::builder("multiple_oral")
.bolus(0.0, 100.0, 0) .observation(1.0, 0.0, 0)
.observation(2.0, 0.0, 0)
.observation(4.0, 0.0, 0)
.bolus(8.0, 100.0, 0) .observation(8.0, 0.0, 0)
.observation(9.0, 0.0, 0)
.observation(10.0, 0.0, 0)
.observation(12.0, 0.0, 0)
.bolus(16.0, 100.0, 0) .observation(16.0, 0.0, 0)
.observation(17.0, 0.0, 0)
.observation(20.0, 0.0, 0)
.observation(24.0, 0.0, 0)
.build();
let analytical = equation::Analytical::new(
one_compartment_with_absorption,
|_p, _t, _cov| {},
|_p, _t, _cov| lag! {},
|_p, _t, _cov| fa! {},
|_p, _t, _cov, _x| {},
|x, p, _t, _cov, y| {
fetch_params!(p, _ka, _ke, v);
y[0] = x[1] / v;
},
)
.with_nstates(2)
.with_nout(1);
let ode = equation::ODE::new(
|x, p, _t, dx, b, _rateiv, _cov| {
fetch_params!(p, ka, ke, _v);
dx[0] = -ka * x[0] + b[0];
dx[1] = ka * x[0] - ke * x[1];
},
|_p, _t, _cov| lag! {},
|_p, _t, _cov| fa! {},
|_p, _t, _cov, _x| {},
|x, p, _t, _cov, y| {
fetch_params!(p, _ka, _ke, v);
y[0] = x[1] / v;
},
)
.with_nstates(2)
.with_nout(1);
assert_ode_matches_analytical(
"multiple_oral_doses",
&analytical,
&ode,
&subject,
&[1.0, 0.1, 50.0],
);
}
#[test]
fn single_infusion_matches_analytical() {
let subject = Subject::builder("single_infusion")
.infusion(0.0, 100.0, 0, 2.0) .observation(0.5, 0.0, 0)
.observation(1.0, 0.0, 0)
.observation(2.0, 0.0, 0) .observation(3.0, 0.0, 0)
.observation(4.0, 0.0, 0)
.observation(8.0, 0.0, 0)
.observation(12.0, 0.0, 0)
.build();
let analytical = equation::Analytical::new(
one_compartment,
|_p, _t, _cov| {},
|_p, _t, _cov| lag! {},
|_p, _t, _cov| fa! {},
|_p, _t, _cov, _x| {},
|x, p, _t, _cov, y| {
fetch_params!(p, _ke, v);
y[0] = x[0] / v;
},
)
.with_nstates(1)
.with_nout(1);
let ode = equation::ODE::new(
|x, p, _t, dx, _b, rateiv, _cov| {
fetch_params!(p, ke, _v);
dx[0] = -ke * x[0] + rateiv[0];
},
|_p, _t, _cov| lag! {},
|_p, _t, _cov| fa! {},
|_p, _t, _cov, _x| {},
|x, p, _t, _cov, y| {
fetch_params!(p, _ke, v);
y[0] = x[0] / v;
},
)
.with_nstates(1)
.with_nout(1);
assert_ode_matches_analytical("single_infusion", &analytical, &ode, &subject, &[0.1, 50.0]);
}
#[test]
fn overlapping_infusions_match_analytical() {
let subject = Subject::builder("overlapping_infusions")
.infusion(0.0, 100.0, 0, 4.0) .infusion(2.0, 50.0, 0, 2.0) .observation(1.0, 0.0, 0)
.observation(2.0, 0.0, 0)
.observation(3.0, 0.0, 0)
.observation(4.0, 0.0, 0)
.observation(5.0, 0.0, 0)
.observation(6.0, 0.0, 0)
.observation(8.0, 0.0, 0)
.observation(12.0, 0.0, 0)
.build();
let analytical = equation::Analytical::new(
one_compartment,
|_p, _t, _cov| {},
|_p, _t, _cov| lag! {},
|_p, _t, _cov| fa! {},
|_p, _t, _cov, _x| {},
|x, p, _t, _cov, y| {
fetch_params!(p, _ke, v);
y[0] = x[0] / v;
},
)
.with_nstates(1)
.with_nout(1);
let ode = equation::ODE::new(
|x, p, _t, dx, _b, rateiv, _cov| {
fetch_params!(p, ke, _v);
dx[0] = -ke * x[0] + rateiv[0];
},
|_p, _t, _cov| lag! {},
|_p, _t, _cov| fa! {},
|_p, _t, _cov, _x| {},
|x, p, _t, _cov, y| {
fetch_params!(p, _ke, v);
y[0] = x[0] / v;
},
)
.with_nstates(1)
.with_nout(1);
assert_ode_matches_analytical(
"overlapping_infusions",
&analytical,
&ode,
&subject,
&[0.1, 50.0],
);
}
#[test]
fn bolus_plus_infusion_matches_analytical() {
let subject = Subject::builder("bolus_plus_infusion")
.bolus(0.0, 100.0, 0) .infusion(0.0, 200.0, 0, 8.0) .observation(1.0, 0.0, 0)
.observation(2.0, 0.0, 0)
.observation(4.0, 0.0, 0)
.observation(8.0, 0.0, 0) .observation(10.0, 0.0, 0)
.observation(12.0, 0.0, 0)
.observation(24.0, 0.0, 0)
.build();
let analytical = equation::Analytical::new(
one_compartment,
|_p, _t, _cov| {},
|_p, _t, _cov| lag! {},
|_p, _t, _cov| fa! {},
|_p, _t, _cov, _x| {},
|x, p, _t, _cov, y| {
fetch_params!(p, _ke, v);
y[0] = x[0] / v;
},
)
.with_nstates(1)
.with_nout(1);
let ode = equation::ODE::new(
|x, p, _t, dx, b, rateiv, _cov| {
fetch_params!(p, ke, _v);
dx[0] = -ke * x[0] + b[0] + rateiv[0];
},
|_p, _t, _cov| lag! {},
|_p, _t, _cov| fa! {},
|_p, _t, _cov, _x| {},
|x, p, _t, _cov, y| {
fetch_params!(p, _ke, v);
y[0] = x[0] / v;
},
)
.with_nstates(1)
.with_nout(1);
assert_ode_matches_analytical(
"bolus_plus_infusion",
&analytical,
&ode,
&subject,
&[0.1, 50.0],
);
}
#[test]
fn complex_dosing_scenario_matches_analytical() {
let subject = Subject::builder("complex_dosing")
.bolus(0.0, 100.0, 0) .observation(1.0, 0.0, 0)
.observation(2.0, 0.0, 0)
.observation(4.0, 0.0, 0)
.bolus(6.0, 150.0, 0) .observation(6.0, 0.0, 0)
.observation(7.0, 0.0, 0)
.observation(8.0, 0.0, 0)
.bolus(12.0, 100.0, 0) .observation(12.0, 0.0, 0)
.observation(14.0, 0.0, 0)
.observation(18.0, 0.0, 0)
.observation(24.0, 0.0, 0)
.build();
let analytical = equation::Analytical::new(
one_compartment_with_absorption,
|_p, _t, _cov| {},
|_p, _t, _cov| lag! {},
|_p, _t, _cov| fa! {},
|_p, _t, _cov, _x| {},
|x, p, _t, _cov, y| {
fetch_params!(p, _ka, _ke, v);
y[0] = x[1] / v;
},
)
.with_nstates(2)
.with_nout(1);
let ode = equation::ODE::new(
|x, p, _t, dx, b, _rateiv, _cov| {
fetch_params!(p, ka, ke, _v);
dx[0] = -ka * x[0] + b[0]; dx[1] = ka * x[0] - ke * x[1]; },
|_p, _t, _cov| lag! {},
|_p, _t, _cov| fa! {},
|_p, _t, _cov, _x| {},
|x, p, _t, _cov, y| {
fetch_params!(p, _ka, _ke, v);
y[0] = x[1] / v;
},
)
.with_nstates(2)
.with_nout(1);
assert_ode_matches_analytical(
"complex_dosing",
&analytical,
&ode,
&subject,
&[1.0, 0.1, 50.0],
);
}
#[test]
fn mixed_bolus_infusion_iv_matches_analytical() {
let subject = Subject::builder("mixed_iv")
.bolus(0.0, 100.0, 0) .observation(1.0, 0.0, 0)
.observation(2.0, 0.0, 0)
.infusion(4.0, 200.0, 0, 4.0) .observation(4.0, 0.0, 0)
.observation(5.0, 0.0, 0)
.observation(6.0, 0.0, 0)
.bolus(8.0, 50.0, 0) .observation(8.0, 0.0, 0)
.observation(9.0, 0.0, 0)
.observation(10.0, 0.0, 0)
.observation(12.0, 0.0, 0)
.observation(24.0, 0.0, 0)
.build();
let analytical = equation::Analytical::new(
one_compartment,
|_p, _t, _cov| {},
|_p, _t, _cov| lag! {},
|_p, _t, _cov| fa! {},
|_p, _t, _cov, _x| {},
|x, p, _t, _cov, y| {
fetch_params!(p, _ke, v);
y[0] = x[0] / v;
},
)
.with_nstates(1)
.with_nout(1);
let ode = equation::ODE::new(
|x, p, _t, dx, b, rateiv, _cov| {
fetch_params!(p, ke, _v);
dx[0] = -ke * x[0] + b[0] + rateiv[0];
},
|_p, _t, _cov| lag! {},
|_p, _t, _cov| fa! {},
|_p, _t, _cov, _x| {},
|x, p, _t, _cov, y| {
fetch_params!(p, _ke, v);
y[0] = x[0] / v;
},
)
.with_nstates(1)
.with_nout(1);
assert_ode_matches_analytical(
"mixed_iv_bolus_infusion",
&analytical,
&ode,
&subject,
&[0.1, 50.0],
);
}
#[test]
fn bolus_at_observation_time_matches_analytical() {
let subject = Subject::builder("simultaneous")
.bolus(0.0, 100.0, 0)
.observation(0.0, 0.0, 0) .observation(1.0, 0.0, 0)
.bolus(2.0, 50.0, 0)
.observation(2.0, 0.0, 0) .observation(3.0, 0.0, 0)
.observation(4.0, 0.0, 0)
.build();
let analytical = equation::Analytical::new(
one_compartment,
|_p, _t, _cov| {},
|_p, _t, _cov| lag! {},
|_p, _t, _cov| fa! {},
|_p, _t, _cov, _x| {},
|x, p, _t, _cov, y| {
fetch_params!(p, _ke, v);
y[0] = x[0] / v;
},
)
.with_nstates(1)
.with_nout(1);
let ode = equation::ODE::new(
|x, p, _t, dx, b, _rateiv, _cov| {
fetch_params!(p, ke, _v);
dx[0] = -ke * x[0] + b[0];
},
|_p, _t, _cov| lag! {},
|_p, _t, _cov| fa! {},
|_p, _t, _cov, _x| {},
|x, p, _t, _cov, y| {
fetch_params!(p, _ke, v);
y[0] = x[0] / v;
},
)
.with_nstates(1)
.with_nout(1);
assert_ode_matches_analytical(
"bolus_at_observation_time",
&analytical,
&ode,
&subject,
&[0.1, 50.0],
);
}
#[test]
fn very_fast_elimination_matches_analytical() {
let subject = Subject::builder("fast_elimination")
.bolus(0.0, 100.0, 0)
.observation(0.1, 0.0, 0)
.observation(0.2, 0.0, 0)
.observation(0.5, 0.0, 0)
.observation(1.0, 0.0, 0)
.observation(2.0, 0.0, 0)
.build();
let analytical = equation::Analytical::new(
one_compartment,
|_p, _t, _cov| {},
|_p, _t, _cov| lag! {},
|_p, _t, _cov| fa! {},
|_p, _t, _cov, _x| {},
|x, p, _t, _cov, y| {
fetch_params!(p, _ke, v);
y[0] = x[0] / v;
},
)
.with_nstates(1)
.with_nout(1);
let ode = equation::ODE::new(
|x, p, _t, dx, b, _rateiv, _cov| {
fetch_params!(p, ke, _v);
dx[0] = -ke * x[0] + b[0];
},
|_p, _t, _cov| lag! {},
|_p, _t, _cov| fa! {},
|_p, _t, _cov, _x| {},
|x, p, _t, _cov, y| {
fetch_params!(p, _ke, v);
y[0] = x[0] / v;
},
)
.with_nstates(1)
.with_nout(1);
assert_ode_matches_analytical(
"fast_elimination",
&analytical,
&ode,
&subject,
&[2.0, 50.0],
);
}
#[test]
fn very_slow_elimination_matches_analytical() {
let subject = Subject::builder("slow_elimination")
.bolus(0.0, 100.0, 0)
.observation(24.0, 0.0, 0)
.observation(48.0, 0.0, 0)
.observation(72.0, 0.0, 0)
.observation(96.0, 0.0, 0)
.observation(168.0, 0.0, 0) .build();
let analytical = equation::Analytical::new(
one_compartment,
|_p, _t, _cov| {},
|_p, _t, _cov| lag! {},
|_p, _t, _cov| fa! {},
|_p, _t, _cov, _x| {},
|x, p, _t, _cov, y| {
fetch_params!(p, _ke, v);
y[0] = x[0] / v;
},
)
.with_nstates(1)
.with_nout(1);
let ode = equation::ODE::new(
|x, p, _t, dx, b, _rateiv, _cov| {
fetch_params!(p, ke, _v);
dx[0] = -ke * x[0] + b[0];
},
|_p, _t, _cov| lag! {},
|_p, _t, _cov| fa! {},
|_p, _t, _cov, _x| {},
|x, p, _t, _cov, y| {
fetch_params!(p, _ke, v);
y[0] = x[0] / v;
},
)
.with_nstates(1)
.with_nout(1);
assert_ode_matches_analytical(
"slow_elimination",
&analytical,
&ode,
&subject,
&[0.01, 50.0],
);
}
#[test]
fn rapid_absorption_matches_analytical() {
let subject = Subject::builder("rapid_absorption")
.bolus(0.0, 100.0, 0)
.observation(0.1, 0.0, 0)
.observation(0.25, 0.0, 0)
.observation(0.5, 0.0, 0)
.observation(1.0, 0.0, 0)
.observation(2.0, 0.0, 0)
.observation(4.0, 0.0, 0)
.build();
let analytical = equation::Analytical::new(
one_compartment_with_absorption,
|_p, _t, _cov| {},
|_p, _t, _cov| lag! {},
|_p, _t, _cov| fa! {},
|_p, _t, _cov, _x| {},
|x, p, _t, _cov, y| {
fetch_params!(p, _ka, _ke, v);
y[0] = x[1] / v;
},
)
.with_nstates(2)
.with_nout(1);
let ode = equation::ODE::new(
|x, p, _t, dx, b, _rateiv, _cov| {
fetch_params!(p, ka, ke, _v);
dx[0] = -ka * x[0] + b[0];
dx[1] = ka * x[0] - ke * x[1];
},
|_p, _t, _cov| lag! {},
|_p, _t, _cov| fa! {},
|_p, _t, _cov, _x| {},
|x, p, _t, _cov, y| {
fetch_params!(p, _ka, _ke, v);
y[0] = x[1] / v;
},
)
.with_nstates(2)
.with_nout(1);
assert_ode_matches_analytical(
"rapid_absorption",
&analytical,
&ode,
&subject,
&[10.0, 0.1, 50.0],
);
}
#[test]
fn time_varying_covariates_work_correctly() {
let subject = Subject::builder("covariates")
.bolus(0.0, 100.0, 0)
.covariate("wt", 0.0, 70.0)
.observation(1.0, 0.0, 0)
.covariate("wt", 2.0, 75.0) .observation(2.0, 0.0, 0)
.observation(4.0, 0.0, 0)
.covariate("wt", 6.0, 72.0) .observation(6.0, 0.0, 0)
.observation(8.0, 0.0, 0)
.build();
let ode = equation::ODE::new(
|x, p, t, dx, b, _rateiv, cov| {
fetch_params!(p, ke_ref, _v);
fetch_cov!(cov, t, wt);
let ke = ke_ref * (wt / 70.0_f64).powf(0.75);
dx[0] = -ke * x[0] + b[0];
},
|_p, _t, _cov| lag! {},
|_p, _t, _cov| fa! {},
|_p, _t, _cov, _x| {},
|x, p, _t, _cov, y| {
fetch_params!(p, _ke, v);
y[0] = x[0] / v;
},
)
.with_nstates(1)
.with_nout(1);
let result = ode.estimate_predictions(&subject, &vec![0.1, 50.0]);
assert!(result.is_ok(), "ODE with covariates should succeed");
let predictions = result.unwrap();
let preds = predictions.flat_predictions();
for (i, &pred) in preds.iter().enumerate() {
assert!(
pred > 0.0,
"Prediction {} should be positive, got {}",
i,
pred
);
}
for i in 1..preds.len() {
assert!(
preds[i] < 3.0, "Prediction {} seems too high: {}",
i,
preds[i]
);
}
}
#[test]
fn likelihood_calculation_matches_analytical() {
let subject = Subject::builder("likelihood")
.bolus(0.0, 100.0, 0)
.observation(1.0, 1.8, 0) .observation(2.0, 1.6, 0)
.observation(4.0, 1.3, 0)
.observation(8.0, 0.8, 0)
.build();
let analytical = equation::Analytical::new(
one_compartment,
|_p, _t, _cov| {},
|_p, _t, _cov| lag! {},
|_p, _t, _cov| fa! {},
|_p, _t, _cov, _x| {},
|x, p, _t, _cov, y| {
fetch_params!(p, _ke, v);
y[0] = x[0] / v;
},
)
.with_nstates(1)
.with_nout(1);
let ode = equation::ODE::new(
|x, p, _t, dx, b, _rateiv, _cov| {
fetch_params!(p, ke, _v);
dx[0] = -ke * x[0] + b[0];
},
|_p, _t, _cov| lag! {},
|_p, _t, _cov| fa! {},
|_p, _t, _cov, _x| {},
|x, p, _t, _cov, y| {
fetch_params!(p, _ke, v);
y[0] = x[0] / v;
},
)
.with_nstates(1)
.with_nout(1);
let error_models = AssayErrorModels::new()
.add(
0,
AssayErrorModel::additive(ErrorPoly::new(0.0, 0.1, 0.0, 0.0), 0.0),
)
.unwrap();
let params = vec![0.1, 50.0];
let ll_analytical = analytical
.estimate_log_likelihood(&subject, ¶ms, &error_models)
.expect("analytical likelihood")
.exp();
let ll_ode = ode
.estimate_log_likelihood(&subject, ¶ms, &error_models)
.expect("ode likelihood")
.exp();
let ll_diff = (ll_analytical - ll_ode).abs();
let ll_rel_diff = ll_diff / ll_analytical.abs().max(1e-10);
assert!(
ll_rel_diff < 0.01, "Likelihoods should match: analytical={:.6}, ode={:.6}, rel_diff={:.2e}",
ll_analytical,
ll_ode,
ll_rel_diff
);
}