mod dissimilarity;
mod reference;
use chrom_rs::models::{LangmuirSingle, TemporalInjection};
use chrom_rs::physics::{PhysicalData, PhysicalModel, PhysicalQuantity};
use chrom_rs::solver::{
DomainBoundaries, EulerSolver, RK4Solver, Scenario, Solver, SolverConfiguration,
};
use dissimilarity::{rsf, trapezoid};
use reference::{
COLUMN_LENGTH, KI, LAMBDA, N_POINTS, N_STEPS, POROSITY, PORT_NUMBER, ReferenceCase, T_INJ,
T_TOTAL, VELOCITY,
};
fn outlet_profile(result: &chrom_rs::solver::SimulationResult, n_points: usize) -> Vec<f64> {
result
.state_trajectory
.iter()
.filter_map(|state| {
state
.get(PhysicalQuantity::Concentration)
.map(|data| match data {
PhysicalData::Vector(v) => v[n_points - 1],
PhysicalData::Matrix(m) => m[(n_points - 1, 0)],
_ => 0.0,
})
})
.collect()
}
fn run_rk4(c0: f64) -> (Vec<f64>, Vec<f64>) {
let injection = TemporalInjection::rectangle(0.0, T_INJ, c0);
let model = LangmuirSingle::new(
LAMBDA,
KI,
PORT_NUMBER,
POROSITY,
VELOCITY,
COLUMN_LENGTH,
N_POINTS,
injection,
);
let n_pts = model.points();
let boundaries = DomainBoundaries::temporal(model.setup_initial_state());
let scenario = Scenario::new(Box::new(model), boundaries);
let config = SolverConfiguration::time_evolution(T_TOTAL, N_STEPS);
let result = RK4Solver::new()
.solve(&scenario, &config)
.expect("RK4 solver failed");
let c_out = outlet_profile(&result, n_pts);
(result.time_points, c_out)
}
fn run_euler(c0: f64) -> (Vec<f64>, Vec<f64>) {
let injection = TemporalInjection::rectangle(0.0, T_INJ, c0);
let model = LangmuirSingle::new(
LAMBDA,
KI,
PORT_NUMBER,
POROSITY,
VELOCITY,
COLUMN_LENGTH,
N_POINTS,
injection,
);
let n_pts = model.points();
let boundaries = DomainBoundaries::temporal(model.setup_initial_state());
let scenario = Scenario::new(Box::new(model), boundaries);
let config = SolverConfiguration::time_evolution(T_TOTAL, N_STEPS);
let result = EulerSolver::new()
.solve(&scenario, &config)
.expect("Euler solver failed");
let c_out = outlet_profile(&result, n_pts);
(result.time_points, c_out)
}
fn first_moment(times: &[f64], concs: &[f64]) -> f64 {
let area = trapezoid(times, concs);
let num: Vec<f64> = times.iter().zip(concs).map(|(t, c)| t * c).collect();
trapezoid(times, &num) / area
}
fn peak_sigma(times: &[f64], concs: &[f64]) -> f64 {
let t_mean = first_moment(times, concs);
let area = trapezoid(times, concs);
let num: Vec<f64> = times
.iter()
.zip(concs)
.map(|(t, c)| (t - t_mean).powi(2) * c)
.collect();
(trapezoid(times, &num) / area).sqrt()
}
#[test]
fn diagnostic_outlet_profile() {
let c0 = ReferenceCase::linear_tfa().c0;
let (t_sim, c_sim) = run_rk4(c0);
let n_t = t_sim.len();
let n_c = c_sim.len();
println!("=== RK4 outlet profile diagnostic ===");
println!("time_points.len() = {n_t}");
println!("c_sim.len() = {n_c}");
println!("t[0] = {:.4} c[0] = {:.4e}", t_sim[0], c_sim[0]);
println!(
"t[last] = {:.4} c[last] = {:.4e}",
t_sim[n_t - 1],
c_sim[n_c - 1]
);
let peak_idx = c_sim
.iter()
.enumerate()
.max_by(|a, b| a.1.partial_cmp(b.1).unwrap())
.map(|(i, _)| i)
.unwrap();
println!(
"Peak: t[{peak_idx}] = {:.4} s, C = {:.4e}",
t_sim[peak_idx.min(n_t - 1)],
c_sim[peak_idx]
);
let nonzero: Vec<usize> = c_sim
.iter()
.enumerate()
.filter(|(_, c)| **c > 1e-15)
.map(|(i, _)| i)
.collect();
if !nonzero.is_empty() {
println!(
"First non-zero: i={}, t={:.2} s",
nonzero[0],
t_sim[nonzero[0].min(n_t - 1)]
);
println!(
"Last non-zero: i={}, t={:.2} s",
nonzero.last().unwrap(),
t_sim[(*nonzero.last().unwrap()).min(n_t - 1)]
);
}
let area = trapezoid(&t_sim, &c_sim);
let area_in = c0 * T_INJ;
println!("Outlet area = {:.6e}", area);
println!("Inlet area = {:.6e} (c0 * T_INJ)", area_in);
println!(
"Mass recovery = {:.4} ({:.1} %)",
area / area_in,
area / area_in * 100.0
);
if area > 0.0 {
let tm = first_moment(&t_sim, &c_sim);
println!("First moment (tR) = {:.2} s", tm);
}
}
#[test]
fn linear_tfa_retention_time() {
let case = ReferenceCase::linear_tfa();
let (t_sim, c_sim) = run_rk4(case.c0);
let t_r_sim = first_moment(&t_sim, &c_sim);
let error_pct = (t_r_sim - case.t_retention).abs() / case.t_retention * 100.0;
println!("Analytical tR = {:.2} s", case.t_retention);
println!("Simulated tR = {:.2} s", t_r_sim);
println!("Error = {:.3} %", error_pct);
assert!(
error_pct < 1.0,
"tR error = {error_pct:.3} % ≥ 1 % for case '{}'",
case.name
);
}
#[test]
fn linear_tfa_peak_width() {
let case = ReferenceCase::linear_tfa();
let sigma_ana = case.sigma_analytical.unwrap();
let (t_sim, c_sim) = run_rk4(case.c0);
let sigma_sim = peak_sigma(&t_sim, &c_sim);
let error_pct = (sigma_sim - sigma_ana).abs() / sigma_ana * 100.0;
println!("Analytical σ = {:.2} s", sigma_ana);
println!("Simulated σ = {:.2} s", sigma_sim);
println!("Error = {:.3} %", error_pct);
assert!(
error_pct < 10.0,
"σ error = {error_pct:.3} % ≥ 10 % for case '{}'",
case.name
);
}
#[test]
fn linear_tfa_mass_conservation() {
let case = ReferenceCase::linear_tfa();
let (t_sim, c_sim) = run_rk4(case.c0);
let area_out = trapezoid(&t_sim, &c_sim);
let area_in = case.injected_area();
let recovery = area_out / area_in;
println!("Injected area = {:.6}", area_in);
println!("Outlet area = {:.6}", area_out);
println!(
"Mass recovery = {:.4} ({:.1} %)",
recovery,
recovery * 100.0
);
assert!(
recovery >= case.mass_recovery_min,
"mass recovery = {recovery:.4} < {:.2} for case '{}'",
case.mass_recovery_min,
case.name
);
}
#[test]
fn nonlinear_tfa_peak_compression() {
let case = ReferenceCase::nonlinear_tfa();
let (t_nl, c_nl) = run_rk4(case.c0);
let peak_idx_nl = c_nl
.iter()
.enumerate()
.max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap())
.map(|(i, _)| i)
.unwrap();
let t_mode_nl = t_nl[peak_idx_nl];
let (t_lin, c_lin) = run_rk4(ReferenceCase::linear_tfa().c0);
let peak_idx_lin = c_lin
.iter()
.enumerate()
.max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap())
.map(|(i, _)| i)
.unwrap();
let t_mode_lin = t_lin[peak_idx_lin];
println!("Linear t_mode = {:.2} s", t_mode_lin);
println!("Non-linear t_mode = {:.2} s", t_mode_nl);
println!("Compression = {:.2} s", t_mode_lin - t_mode_nl);
assert!(
t_mode_nl < t_mode_lin,
"t_mode NL = {t_mode_nl:.2} s should be < t_mode lin = {t_mode_lin:.2} s \
for case '{}'",
case.name
);
}
#[test]
fn nonlinear_tfa_mass_conservation() {
let case = ReferenceCase::nonlinear_tfa();
let (t_sim, c_sim) = run_rk4(case.c0);
let area_out = trapezoid(&t_sim, &c_sim);
let area_in = case.injected_area();
let recovery = area_out / area_in;
println!(
"Mass recovery = {:.4} ({:.1} %)",
recovery,
recovery * 100.0
);
assert!(
recovery >= case.mass_recovery_min,
"mass recovery = {recovery:.4} < {:.2} for case '{}'",
case.mass_recovery_min,
case.name
);
}
#[test]
fn euler_vs_rk4_rsf() {
let c0 = ReferenceCase::linear_tfa().c0;
let (t_euler, c_euler) = run_euler(c0);
let (t_rk4, c_rk4) = run_rk4(c0);
let criterion = rsf(&t_euler, &c_euler, &t_rk4, &c_rk4);
println!("Rsf(Euler, RK4) = {criterion:.4}");
assert!(
criterion < 0.05,
"Rsf(Euler, RK4) = {criterion:.4} ≥ 0.05 — solver divergence detected"
);
}