use chrom_rs::{
models::{LangmuirMulti, LangmuirSingle, SpeciesParams, TemporalInjection},
output::export::{CsvExporter, Exporter},
output::{
PlotConfig, plot_chromatogram, plot_chromatogram_multi, plot_chromatograms_comparison,
},
physics::{PhysicalData, PhysicalModel, PhysicalQuantity},
solver::{
DomainBoundaries, EulerSolver, RK4Solver, Scenario, SimulationResult, Solver,
SolverConfiguration,
},
};
use std::time::Instant;
struct SoloRun {
species_name: &'static str,
solver_name: &'static str,
elapsed_secs: f64,
peak_concentration: f64,
retention_time_secs: f64,
final_concentration: f64,
result: SimulationResult,
}
struct MultiRun {
solver_name: &'static str,
elapsed_secs: f64,
peak_concentrations: Vec<f64>,
retention_times: Vec<f64>,
result: SimulationResult,
}
fn print_section(title: &str) {
println!("\n═══════════════════════════════════════════════════════");
println!(" {title}");
println!("═══════════════════════════════════════════════════════\n");
}
fn outlet_single(result: &SimulationResult, n_points: usize) -> Vec<f64> {
result
.state_trajectory
.iter()
.map(
|state| match state.get(PhysicalQuantity::Concentration).unwrap() {
PhysicalData::Vector(v) => v[n_points - 1],
_ => 0.0,
},
)
.collect()
}
fn outlet_multi(result: &SimulationResult, n_points: usize, n_species: usize) -> Vec<Vec<f64>> {
let mut per_species: Vec<Vec<f64>> = vec![Vec::new(); n_species];
for state in &result.state_trajectory {
if let Some(PhysicalData::Matrix(m)) = state.get(PhysicalQuantity::Concentration) {
for s in 0..n_species {
per_species[s].push(m[(n_points - 1, s)]);
}
}
}
per_species
}
fn peak_stats(outlet: &[f64], time_points: &[f64]) -> (f64, f64, f64) {
let peak = outlet.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
let idx = outlet
.iter()
.position(|&c| (c - peak).abs() < 1e-12)
.unwrap_or(0);
let rt = time_points[idx];
let final_c = *outlet.last().unwrap_or(&0.0);
(peak, rt, final_c)
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
print_section("Ascorbic / Erythorbic Acid — Solo and Competitive Study");
let n_points = 100;
let porosity = 0.4;
let velocity = 0.001;
let column_length = 0.25;
let params = [
("Ascorbic", 1.0_f64, 1.1_f64, 2.0_f64), ("Erythorbic", 1.0_f64, 1.7_f64, 2.0_f64), ];
let species_names: &[&str] = &["Ascorbic", "Erythorbic"];
let n_species = params.len();
println!("Species:");
for (name, lambda, k, n) in ¶ms {
println!(" {name:<12} λ={lambda} K̃={k} L/mol N={n}");
}
println!("\nColumn:");
println!(" ε={porosity} u={velocity} m/s L={column_length} m nz={n_points}");
let total_time = 1200.0;
let time_steps = 20_000;
println!("\nSimulation:");
println!(" Total time : {total_time} s ({} min)", total_time / 60.0);
println!(" Time steps : {time_steps}");
println!(" dt : {:.6} s", total_time / time_steps as f64);
let injection_center = 10.0; let injection_sigma = 3.0; let injection_peak = 0.1;
println!(
"\nInjection: Gaussian — center={injection_center} s σ={injection_sigma} s peak={injection_peak} mol/L"
);
let configuration = SolverConfiguration::time_evolution(total_time, time_steps);
let tmp_dir = std::env::temp_dir();
let exporter = CsvExporter::default();
let solver_defs: Vec<(&'static str, Box<dyn Solver>)> = vec![
("Euler", Box::new(EulerSolver::new())),
("Runge-Kutta", Box::new(RK4Solver::new())),
];
print_section("Phase 1 — Solo Runs: 2 Species × 2 Solvers");
let mut solo_runs: Vec<SoloRun> = Vec::new();
for (species_name, lambda, langmuir_k, port_number) in ¶ms {
for (solver_name, solver) in &solver_defs {
print!(" {species_name} × {solver_name} … ");
std::io::Write::flush(&mut std::io::stdout())?;
let timer = Instant::now();
let model = LangmuirSingle::new(
*lambda,
*langmuir_k,
*port_number,
porosity,
velocity,
column_length,
n_points,
TemporalInjection::gaussian(injection_center, injection_sigma, injection_peak),
);
let initial = model.setup_initial_state();
let boundaries = DomainBoundaries::temporal(initial);
let scenario = Scenario::new(Box::new(model), boundaries);
let result = solver.solve(&scenario, &configuration)?;
let elapsed_secs = timer.elapsed().as_secs_f64();
let outlet = outlet_single(&result, n_points);
let (peak, rt, final_c) = peak_stats(&outlet, &result.time_points);
println!("✓ {:.2} s", elapsed_secs);
let png_name = format!("acids_solo_{}_{}.png", species_name, solver_name);
let png_path = tmp_dir.join(&png_name);
let cfg = PlotConfig::chromatogram(format!("{species_name} — {solver_name}"));
plot_chromatogram(&result, n_points, png_path.to_str().unwrap(), Some(&cfg))?;
println!(" plot → {:?}", png_path);
let csv_name = format!("acids_solo_{}_{}.csv", species_name, solver_name);
let csv_path = tmp_dir.join(&csv_name);
exporter.export_single(&result, None, csv_path.to_str().unwrap())?;
println!(" csv → {:?}", csv_path);
solo_runs.push(SoloRun {
species_name,
solver_name,
elapsed_secs,
peak_concentration: peak,
retention_time_secs: rt,
final_concentration: final_c,
result,
});
}
}
for solver_name in ["Euler", "Runge-Kutta"] {
let datasets: Vec<(&str, &SimulationResult, usize)> = solo_runs
.iter()
.filter(|r| r.solver_name == solver_name)
.map(|r| (r.species_name, &r.result, n_points))
.collect();
let cmp_name = format!("acids_solo_comparison_{solver_name}.png");
let cmp_path = tmp_dir.join(&cmp_name);
let cfg = PlotConfig::chromatogram(format!("Ascorbic / Erythorbic — Solo, {solver_name}"));
plot_chromatograms_comparison(datasets, cmp_path.to_str().unwrap(), Some(&cfg))?;
println!("\n Comparison ({solver_name}) → {:?}", cmp_path);
}
print_section("Phase 2 — Competitive Runs: LangmuirMulti × 2 Solvers");
let mut multi_runs: Vec<MultiRun> = Vec::new();
for (solver_name, solver) in &solver_defs {
print!(" Competitive × {solver_name} … ");
std::io::Write::flush(&mut std::io::stdout())?;
let timer = Instant::now();
let species_vec: Vec<SpeciesParams> = params
.iter()
.map(|(name, lambda, k, n)| {
SpeciesParams::new(
*name,
*lambda,
*k,
*n as u32,
TemporalInjection::gaussian(injection_center, injection_sigma, injection_peak),
)
})
.collect();
let model = LangmuirMulti::new(species_vec, n_points, porosity, velocity, column_length)?;
let initial = model.setup_initial_state();
let boundaries = DomainBoundaries::temporal(initial);
let scenario = Scenario::new(Box::new(model), boundaries);
let result = solver.solve(&scenario, &configuration)?;
let elapsed_secs = timer.elapsed().as_secs_f64();
let per_species = outlet_multi(&result, n_points, n_species);
let mut peak_concentrations = Vec::with_capacity(n_species);
let mut retention_times = Vec::with_capacity(n_species);
for s in 0..n_species {
let (peak, rt, _) = peak_stats(&per_species[s], &result.time_points);
peak_concentrations.push(peak);
retention_times.push(rt);
}
println!("✓ {:.2} s", elapsed_secs);
let png_name = format!("acids_multi_{solver_name}.png");
let png_path = tmp_dir.join(&png_name);
let cfg = PlotConfig::chromatogram(format!(
"Ascorbic / Erythorbic — Competitive, {solver_name}"
));
plot_chromatogram_multi(
&result,
n_points,
species_names,
png_path.to_str().unwrap(),
Some(&cfg),
)?;
println!(" plot → {:?}", png_path);
let csv_name = format!("acids_multi_{solver_name}.csv");
let csv_path = tmp_dir.join(&csv_name);
exporter.export_multi(&result, None, species_names, csv_path.to_str().unwrap())?;
println!(" csv → {:?}", csv_path);
multi_runs.push(MultiRun {
solver_name,
elapsed_secs,
peak_concentrations,
retention_times,
result,
});
}
print_section("Results — Solo: Peak Characteristics");
println!(
"{:<12} {:<14} {:>12} {:>12} {:>12}",
"Species", "Solver", "Peak (mol/L)", "Ret.Time (s)", "Final (mol/L)"
);
println!("{:-<65}", "");
for run in &solo_runs {
println!(
"{:<12} {:<14} {:>12.6} {:>12.1} {:>12.6}",
run.species_name,
run.solver_name,
run.peak_concentration,
run.retention_time_secs,
run.final_concentration
);
}
print_section("Results — Competitive: Peak Characteristics");
println!(
"{:<12} {:<14} {:>12} {:>12}",
"Species", "Solver", "Peak (mol/L)", "Ret.Time (s)"
);
println!("{:-<55}", "");
for run in &multi_runs {
for s in 0..n_species {
println!(
"{:<12} {:<14} {:>12.6} {:>12.1}",
species_names[s],
run.solver_name,
run.peak_concentrations[s],
run.retention_times[s]
);
}
println!("{:-<55}", "");
}
print_section("Performance Comparison");
println!("{:<12} {:<14} {:>12}", "Phase", "Solver", "Time (s)");
println!("{:-<40}", "");
for run in &solo_runs {
println!(
"{:<12} {:<14} {:>12.3}",
format!("Solo-{}", run.species_name),
run.solver_name,
run.elapsed_secs
);
}
for run in &multi_runs {
println!(
"{:<12} {:<14} {:>12.3}",
"Competitive", run.solver_name, run.elapsed_secs
);
}
if multi_runs.len() >= 2 {
let ratio = multi_runs[1].elapsed_secs / multi_runs[0].elapsed_secs;
println!(
"\nRK4/Euler ratio (competitive): {:.2}× slower (expected ~4×)",
ratio
);
}
print_section("Accuracy: Euler vs RK4");
println!("— Solo —");
for species_name in species_names {
let euler = solo_runs
.iter()
.find(|r| r.species_name == *species_name && r.solver_name == "Euler")
.unwrap();
let rk4 = solo_runs
.iter()
.find(|r| r.species_name == *species_name && r.solver_name == "Runge-Kutta")
.unwrap();
let peak_diff = (rk4.peak_concentration - euler.peak_concentration).abs();
let peak_pct = (peak_diff / euler.peak_concentration) * 100.0;
let rt_diff = (rk4.retention_time_secs - euler.retention_time_secs).abs();
let rt_ppm = (rt_diff / euler.retention_time_secs) * 1_000_000.0;
println!("\n {species_name}:");
println!(
" Peak difference : {:.6} mol/L ({:.2}%)",
peak_diff, peak_pct
);
println!(
" Ret. time difference: {:.4} s ({:.2} ppm)",
rt_diff, rt_ppm
);
}
if multi_runs.len() >= 2 {
println!("\n— Competitive —");
let euler = &multi_runs[0];
let rk4 = &multi_runs[1];
for s in 0..n_species {
let peak_diff = (rk4.peak_concentrations[s] - euler.peak_concentrations[s]).abs();
let peak_pct = (peak_diff / euler.peak_concentrations[s]) * 100.0;
let rt_diff = (rk4.retention_times[s] - euler.retention_times[s]).abs();
let rt_ppm = (rt_diff / euler.retention_times[s]) * 1_000_000.0;
println!("\n {} (competitive):", species_names[s]);
println!(
" Peak difference : {:.6} mol/L ({:.2}%)",
peak_diff, peak_pct
);
println!(
" Ret. time difference: {:.4} s ({:.2} ppm)",
rt_diff, rt_ppm
);
}
}
print_section("Peak Validation");
println!("— Solo —");
for run in &solo_runs {
let is_peak =
run.peak_concentration > 1e-4 && run.final_concentration < run.peak_concentration * 0.1;
let status = if is_peak {
"✅ PEAK"
} else {
"❌ PLATEAU / INCOMPLETE"
};
println!(
" {:<12} {:<14} : {}",
run.species_name, run.solver_name, status
);
}
println!("\n— Competitive —");
for run in &multi_runs {
for s in 0..n_species {
let peak = run.peak_concentrations[s];
let per_sp = outlet_multi(&run.result, n_points, n_species);
let final_c = *per_sp[s].last().unwrap_or(&0.0);
let is_peak = peak > 1e-4 && final_c < peak * 0.1;
let status = if is_peak {
"✅ PEAK"
} else {
"❌ PLATEAU / INCOMPLETE"
};
println!(
" {:<12} {:<14} : {}",
species_names[s], run.solver_name, status
);
}
}
print_section("Competition Effect — Retention Time Shift (Euler)");
let euler_multi = multi_runs
.iter()
.find(|r| r.solver_name == "Euler")
.unwrap();
println!(
"{:<12} {:>12} {:>12} {:>12}",
"Species", "Solo (s)", "Mixed (s)", "Shift (s)"
);
println!("{:-<50}", "");
for s in 0..n_species {
let solo_euler = solo_runs
.iter()
.find(|r| r.species_name == species_names[s] && r.solver_name == "Euler")
.unwrap();
let rt_solo = solo_euler.retention_time_secs;
let rt_mixed = euler_multi.retention_times[s];
let shift = rt_mixed - rt_solo;
println!(
"{:<12} {:>12.1} {:>12.1} {:>+12.1}",
species_names[s], rt_solo, rt_mixed, shift
);
}
println!();
for s in 0..n_species {
let solo_euler = solo_runs
.iter()
.find(|r| r.species_name == species_names[s] && r.solver_name == "Euler")
.unwrap();
let rt_solo = solo_euler.retention_time_secs;
let rt_mixed = euler_multi.retention_times[s];
let shift = rt_mixed - rt_solo;
let effect = match shift {
sh if sh.abs() < 1.0 => {
"no significant shift (competition negligible at these concentrations)"
}
sh if sh > 0.0 => "elutes LATER in mixture (competitor occupies shared sites)",
_ => "elutes EARLIER in mixture (displaced by stronger competitor)",
};
println!(" {} : {}", species_names[s], effect);
}
println!("\nDone.");
Ok(())
}