#![allow(clippy::doc_overindented_list_items)]
use plotters::prelude::*;
use std::error::Error;
use super::config::{NO_TITLE, PlotConfig};
use crate::physics::{PhysicalData, PhysicalQuantity};
use crate::solver::SimulationResult;
fn extract_single_species_outlet(result: &SimulationResult, n_points: usize) -> Vec<f64> {
result
.state_trajectory
.iter()
.map(|state| {
match state.get(PhysicalQuantity::Concentration) {
Some(PhysicalData::Scalar(c)) => *c,
Some(PhysicalData::Vector(profile)) => {
profile[n_points - 1]
}
Some(PhysicalData::Matrix(m)) => {
m[(n_points - 1, 0)]
}
_ => 0.0,
}
})
.collect()
}
fn extract_multi_species_outlet(
result: &SimulationResult,
n_points: usize,
n_species: usize,
) -> Vec<Vec<f64>> {
let mut outlets: Vec<Vec<f64>> = (0..n_species).map(|_| Vec::new()).collect();
for state in &result.state_trajectory {
match state.get(PhysicalQuantity::Concentration) {
Some(PhysicalData::Matrix(m)) => {
let outlet_row = n_points - 1;
for k in 0..n_species.min(m.ncols()) {
outlets[k].push(m[(outlet_row, k)]);
}
}
Some(PhysicalData::Vector(profile)) => {
if !outlets.is_empty() {
outlets[0].push(profile[n_points - 1]);
}
}
_ => {
for outlet in outlets.iter_mut() {
outlet.push(0.0);
}
}
}
}
outlets
}
pub fn plot_chromatogram(
result: &SimulationResult,
n_points: usize,
output_path: &str,
config: Option<&PlotConfig>,
) -> Result<(), Box<dyn Error>> {
let outlet = extract_single_species_outlet(result, n_points);
let time_points = &result.time_points;
let default_config = PlotConfig::chromatogram(NO_TITLE);
let config = config.unwrap_or(&default_config);
let max_time = time_points.last().copied().unwrap_or(1.0);
let max_conc = outlet
.iter()
.cloned()
.fold(f64::NEG_INFINITY, f64::max)
.max(1e-10);
let ext = std::path::Path::new(output_path)
.extension()
.and_then(|s| s.to_str())
.unwrap_or("png");
match ext {
"svg" => {
let backend = SVGBackend::new(output_path, (config.width, config.height));
plot_chromatogram_impl(backend, time_points, &outlet, config, max_time, max_conc)
}
_ => {
let backend = BitMapBackend::new(output_path, (config.width, config.height));
plot_chromatogram_impl(backend, time_points, &outlet, config, max_time, max_conc)
}
}
}
pub fn plot_chromatogram_multi(
result: &SimulationResult,
n_points: usize,
species_names: &[&str],
output_path: &str,
config: Option<&PlotConfig>,
) -> Result<(), Box<dyn Error>> {
let outlets = extract_multi_species_outlet(result, n_points, species_names.len());
if outlets.is_empty() || outlets[0].is_empty() {
return Err("No multi-species concentration data found in trajectory".into());
}
let time_points = &result.time_points;
let n_steps = outlets[0].len();
let cumulative: Vec<f64> = (0..n_steps)
.map(|i| outlets.iter().map(|o| o[i]).sum::<f64>())
.collect();
let default_config = PlotConfig::chromatogram(NO_TITLE);
let config = config.unwrap_or(&default_config);
let max_time = time_points.last().copied().unwrap_or(1.0);
let max_conc = cumulative
.iter()
.cloned()
.fold(f64::NEG_INFINITY, f64::max)
.max(1e-10);
let ext = std::path::Path::new(output_path)
.extension()
.and_then(|s| s.to_str())
.unwrap_or("png");
match ext {
"svg" => {
let backend = SVGBackend::new(output_path, (config.width, config.height));
plot_cumulative_chromatogram_impl(
backend,
time_points,
&outlets,
&cumulative,
species_names,
config,
max_time,
max_conc,
)
}
_ => {
let backend = BitMapBackend::new(output_path, (config.width, config.height));
plot_cumulative_chromatogram_impl(
backend,
time_points,
&outlets,
&cumulative,
species_names,
config,
max_time,
max_conc,
)
}
}
}
pub fn plot_chromatogram_envelope(
result: &SimulationResult,
n_points: usize,
species_names: &[&str],
output_path: &str,
config: Option<&PlotConfig>,
) -> Result<(), Box<dyn Error>> {
let outlets = extract_multi_species_outlet(result, n_points, species_names.len());
if outlets.is_empty() || outlets[0].is_empty() {
return Err("No multi-species concentration data found in trajectory".into());
}
let time_points = &result.time_points;
let default_config = PlotConfig::chromatogram(NO_TITLE);
let config = config.unwrap_or(&default_config);
let max_time = time_points.last().copied().unwrap_or(1.0);
let max_conc = outlets
.iter()
.flat_map(|o| o.iter())
.cloned()
.fold(f64::NEG_INFINITY, f64::max)
.max(1e-10);
let ext = std::path::Path::new(output_path)
.extension()
.and_then(|s| s.to_str())
.unwrap_or("png");
match ext {
"svg" => {
let backend = SVGBackend::new(output_path, (config.width, config.height));
plot_envelope_impl(
backend,
time_points,
&outlets,
species_names,
config,
max_time,
max_conc,
)
}
_ => {
let backend = BitMapBackend::new(output_path, (config.width, config.height));
plot_envelope_impl(
backend,
time_points,
&outlets,
species_names,
config,
max_time,
max_conc,
)
}
}
}
pub fn plot_chromatograms_comparison(
datasets: Vec<(&str, &SimulationResult, usize)>,
output_path: &str,
config: Option<&PlotConfig>,
) -> Result<(), Box<dyn Error>> {
if datasets.is_empty() {
return Err("No datasets provided".into());
}
let default_config = PlotConfig::chromatogram(NO_TITLE);
let config = config.unwrap_or(&default_config);
let all_data: Vec<(&str, &[f64], Vec<f64>)> = datasets
.iter()
.map(|(label, result, n_points)| {
let outlet = extract_single_species_outlet(result, *n_points);
(*label, result.time_points.as_slice(), outlet)
})
.collect();
let max_time = all_data
.iter()
.map(|(_, times, _)| times.last().copied().unwrap_or(0.0))
.fold(0.0_f64, f64::max);
let max_conc = all_data
.iter()
.flat_map(|(_, _, outlet)| outlet.iter())
.cloned()
.fold(f64::NEG_INFINITY, f64::max)
.max(1e-10);
let ext = std::path::Path::new(output_path)
.extension()
.and_then(|s| s.to_str())
.unwrap_or("png");
match ext {
"svg" => {
let backend = SVGBackend::new(output_path, (config.width, config.height));
plot_comparison_impl(backend, &all_data, config, max_time, max_conc)
}
_ => {
let backend = BitMapBackend::new(output_path, (config.width, config.height));
plot_comparison_impl(backend, &all_data, config, max_time, max_conc)
}
}
}
fn plot_chromatogram_impl<DB: DrawingBackend>(
backend: DB,
time_points: &[f64],
outlet: &[f64],
config: &PlotConfig,
max_time: f64,
max_conc: f64,
) -> Result<(), Box<dyn Error>>
where
DB::ErrorType: 'static,
{
let root = backend.into_drawing_area();
root.fill(&config.background)?;
let mut chart = ChartBuilder::on(&root)
.caption(&config.title, ("sans-serif", 40).into_font())
.margin(15)
.x_label_area_size(45)
.y_label_area_size(60)
.build_cartesian_2d(0.0..max_time, 0.0..(max_conc * 1.1))?;
if config.show_grid {
chart
.configure_mesh()
.x_desc(&config.xlabel)
.y_desc(&config.ylabel)
.x_label_formatter(&|x| format!("{:.0}", x))
.y_label_formatter(&|y| format!("{:.3}", y))
.draw()?;
}
chart
.draw_series(LineSeries::new(
time_points.iter().zip(outlet.iter()).map(|(t, c)| (*t, *c)),
ShapeStyle::from(&config.line_color).stroke_width(config.line_width),
))?
.label("Outlet Concentration")
.legend(|(x, y)| PathElement::new(vec![(x, y), (x + 20, y)], config.line_color));
chart
.configure_series_labels()
.background_style(config.background.mix(0.8))
.border_style(BLACK)
.draw()?;
root.present()?;
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn plot_cumulative_chromatogram_impl<DB: DrawingBackend>(
backend: DB,
time_points: &[f64],
outlets: &[Vec<f64>], cumulative: &[f64], species_names: &[&str],
config: &PlotConfig,
max_time: f64,
max_conc: f64,
) -> Result<(), Box<dyn Error>>
where
DB::ErrorType: 'static,
{
let root = backend.into_drawing_area();
root.fill(&config.background)?;
let mut chart = ChartBuilder::on(&root)
.caption(&config.title, ("sans-serif", 40).into_font())
.margin(15)
.x_label_area_size(45)
.y_label_area_size(60)
.build_cartesian_2d(0.0..max_time, 0.0..(max_conc * 1.1))?;
if config.show_grid {
chart
.configure_mesh()
.x_desc(&config.xlabel)
.y_desc(&config.ylabel)
.x_label_formatter(&|x| format!("{:.0}", x))
.y_label_formatter(&|y| format!("{:.3}", y))
.draw()?;
}
for (k, outlet) in outlets.iter().enumerate() {
let base_color = config.get_species_color(k);
let faded_style = ShapeStyle {
color: base_color.mix(0.35),
filled: false,
stroke_width: 1, };
let label = species_names.get(k).copied().unwrap_or("?");
chart
.draw_series(LineSeries::new(
time_points.iter().zip(outlet.iter()).map(|(t, c)| (*t, *c)),
faded_style,
))?
.label(label)
.legend(move |(x, y)| PathElement::new(vec![(x, y), (x + 20, y)], base_color.mix(0.5)));
}
let cumulative_color = RGBColor(150, 150, 150);
chart
.draw_series(LineSeries::new(
time_points
.iter()
.zip(cumulative.iter())
.enumerate()
.filter_map(|(i, (t, c))| if i % 5 < 2 { Some((*t, *c)) } else { None }),
ShapeStyle {
color: cumulative_color.to_rgba(),
filled: false,
stroke_width: config.line_width,
},
))?
.label("Σ Total")
.legend(|(x, y)| PathElement::new(vec![(x, y), (x + 20, y)], BLACK));
chart
.configure_series_labels()
.background_style(config.background.mix(0.8))
.border_style(BLACK)
.draw()?;
root.present()?;
Ok(())
}
fn plot_envelope_impl<DB: DrawingBackend>(
backend: DB,
time_points: &[f64],
outlets: &[Vec<f64>], species_names: &[&str],
config: &PlotConfig,
max_time: f64,
max_conc: f64,
) -> Result<(), Box<dyn Error>>
where
DB::ErrorType: 'static,
{
let root = backend.into_drawing_area();
root.fill(&config.background)?;
let mut chart = ChartBuilder::on(&root)
.caption(&config.title, ("sans-serif", 40).into_font())
.margin(15)
.x_label_area_size(45)
.y_label_area_size(60)
.build_cartesian_2d(0.0..max_time, 0.0..(max_conc * 1.1))?;
if config.show_grid {
chart
.configure_mesh()
.x_desc(&config.xlabel)
.y_desc(&config.ylabel)
.x_label_formatter(&|x| format!("{:.0}", x))
.y_label_formatter(&|y| format!("{:.3}", y))
.draw()?;
}
for (k, outlet) in outlets.iter().enumerate() {
let color = config.get_species_color(k);
let label = species_names.get(k).copied().unwrap_or("?");
chart
.draw_series(LineSeries::new(
time_points.iter().zip(outlet.iter()).map(|(t, c)| (*t, *c)),
ShapeStyle::from(&color).stroke_width(config.line_width),
))?
.label(label)
.legend(move |(x, y)| PathElement::new(vec![(x, y), (x + 20, y)], color));
}
if !outlets.is_empty() {
let n_steps = outlets[0].len();
let envelope: Vec<f64> = (0..n_steps)
.map(|i| {
outlets
.iter()
.map(|o| o[i])
.fold(f64::NEG_INFINITY, f64::max)
})
.collect();
let envelope_color = RGBColor(150, 150, 150);
let envelope_style = ShapeStyle {
color: envelope_color.to_rgba(),
filled: false,
stroke_width: config.line_width, };
chart
.draw_series(
LineSeries::new(
time_points
.iter()
.zip(envelope.iter())
.enumerate()
.filter_map(|(i, (t, c))| if i % 2 == 0 { Some((*t, *c)) } else { None }),
envelope_style,
),
)?
.label("Envelope")
.legend(move |(x, y)| PathElement::new(vec![(x, y), (x + 20, y)], envelope_color));
}
chart
.configure_series_labels()
.background_style(config.background.mix(0.8))
.border_style(BLACK)
.draw()?;
root.present()?;
Ok(())
}
fn plot_comparison_impl<DB: DrawingBackend>(
backend: DB,
datasets: &[(&str, &[f64], Vec<f64>)],
config: &PlotConfig,
max_time: f64,
max_conc: f64,
) -> Result<(), Box<dyn Error>>
where
DB::ErrorType: 'static,
{
let root = backend.into_drawing_area();
root.fill(&config.background)?;
let mut chart = ChartBuilder::on(&root)
.caption(&config.title, ("sans-serif", 40).into_font())
.margin(15)
.x_label_area_size(45)
.y_label_area_size(60)
.build_cartesian_2d(0.0..max_time, 0.0..(max_conc * 1.1))?;
if config.show_grid {
chart
.configure_mesh()
.x_desc(&config.xlabel)
.y_desc(&config.ylabel)
.x_label_formatter(&|x| format!("{:.0}", x))
.y_label_formatter(&|y| format!("{:.3}", y))
.draw()?;
}
for (idx, (label, times, outlet)) in datasets.iter().enumerate() {
let color = config.get_species_color(idx);
chart
.draw_series(LineSeries::new(
times.iter().zip(outlet.iter()).map(|(t, c)| (*t, *c)),
&color,
))?
.label(*label)
.legend(move |(x, y)| PathElement::new(vec![(x, y), (x + 20, y)], color));
}
chart
.configure_series_labels()
.background_style(config.background.mix(0.8))
.border_style(BLACK)
.draw()?;
root.present()?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::physics::{PhysicalModel, PhysicalQuantity, PhysicalState};
use crate::solver::{DomainBoundaries, EulerSolver, Scenario, Solver, SolverConfiguration};
use nalgebra::{DMatrix, DVector};
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)]
struct SingleModel {
n_points: usize,
}
#[typetag::serde]
impl PhysicalModel for SingleModel {
fn points(&self) -> usize {
self.n_points
}
fn compute_physics(
&self,
state: &PhysicalState,
_ctx: &crate::physics::ComputeContext,
) -> PhysicalState {
let c = state
.get(PhysicalQuantity::Concentration)
.unwrap()
.as_vector();
let dc_dt = DVector::from_element(c.len(), -0.01);
PhysicalState::new(PhysicalQuantity::Concentration, PhysicalData::Vector(dc_dt))
}
fn setup_initial_state(&self) -> PhysicalState {
PhysicalState::new(
PhysicalQuantity::Concentration,
PhysicalData::Vector(DVector::from_element(self.n_points, 1.0)),
)
}
fn name(&self) -> &str {
"SingleModel"
}
}
#[derive(Serialize, Deserialize)]
struct MultiModel {
n_points: usize,
n_species: usize,
}
#[typetag::serde]
impl PhysicalModel for MultiModel {
fn points(&self) -> usize {
self.n_points
}
fn compute_physics(
&self,
state: &PhysicalState,
_ctx: &crate::physics::ComputeContext,
) -> PhysicalState {
let m = state
.get(PhysicalQuantity::Concentration)
.unwrap()
.as_matrix();
let mut dc_dt = DMatrix::zeros(self.n_points, self.n_species);
for k in 0..self.n_species {
let rate = -0.01 * (k + 1) as f64;
for i in 0..self.n_points {
dc_dt[(i, k)] = m[(i, k)] * rate;
}
}
PhysicalState::new(PhysicalQuantity::Concentration, PhysicalData::Matrix(dc_dt))
}
fn setup_initial_state(&self) -> PhysicalState {
let mut c = DMatrix::zeros(self.n_points, self.n_species);
for k in 0..self.n_species {
for i in 0..self.n_points {
c[(i, k)] = (k + 1) as f64 * 0.5;
}
}
PhysicalState::new(PhysicalQuantity::Concentration, PhysicalData::Matrix(c))
}
fn name(&self) -> &str {
"MultiModel"
}
}
fn run_single(n: usize) -> SimulationResult {
let model = Box::new(SingleModel { n_points: n });
let init = model.setup_initial_state();
let scenario = Scenario::new(model, DomainBoundaries::temporal(init));
EulerSolver
.solve(&scenario, &SolverConfiguration::time_evolution(10.0, 100))
.unwrap()
}
fn run_multi(n: usize, k: usize) -> SimulationResult {
let model = Box::new(MultiModel {
n_points: n,
n_species: k,
});
let init = model.setup_initial_state();
let scenario = Scenario::new(model, DomainBoundaries::temporal(init));
EulerSolver
.solve(&scenario, &SolverConfiguration::time_evolution(10.0, 100))
.unwrap()
}
#[test]
fn test_extract_single_outlet_length() {
let result = run_single(10);
let outlet = extract_single_species_outlet(&result, 10);
assert_eq!(outlet.len(), 101); }
#[test]
fn test_extract_single_outlet_initial_value() {
let result = run_single(10);
let outlet = extract_single_species_outlet(&result, 10);
assert!(outlet[0] > 0.9);
}
#[test]
fn test_extract_single_outlet_decreasing() {
let result = run_single(10);
let outlet = extract_single_species_outlet(&result, 10);
assert!(outlet.last().unwrap() < &outlet[0]);
}
#[test]
fn test_extract_multi_outlet_shape() {
let result = run_multi(10, 3);
let outlets = extract_multi_species_outlet(&result, 10, 3);
assert_eq!(outlets.len(), 3);
assert_eq!(outlets[0].len(), 101);
}
#[test]
fn test_extract_multi_outlet_all_finite() {
let result = run_multi(10, 2);
let outlets = extract_multi_species_outlet(&result, 10, 2);
for (k, o) in outlets.iter().enumerate() {
for (i, &v) in o.iter().enumerate() {
assert!(v.is_finite(), "species {k} step {i}: {v}");
}
}
}
#[test]
fn test_extract_multi_outlet_species_differ() {
let result = run_multi(10, 2);
let outlets = extract_multi_species_outlet(&result, 10, 2);
assert!(outlets[1][0] > outlets[0][0]);
}
#[test]
fn test_extract_multi_outlet_independent_decay() {
let result = run_multi(10, 2);
let outlets = extract_multi_species_outlet(&result, 10, 2);
let ratio_start = outlets[1][0] / outlets[0][0];
let ratio_end = outlets[1].last().unwrap() / outlets[0].last().unwrap();
assert!(ratio_end < ratio_start);
}
#[test]
fn test_plot_chromatogram_png() {
let result = run_single(10);
let tmp = tempfile::NamedTempFile::new().unwrap();
let path = tmp.path().with_extension("png");
plot_chromatogram(&result, 10, path.to_str().unwrap(), None).unwrap();
assert!(path.exists());
}
#[test]
fn test_plot_chromatogram_svg() {
let result = run_single(10);
let tmp = tempfile::NamedTempFile::new().unwrap();
let path = tmp.path().with_extension("svg");
plot_chromatogram(&result, 10, path.to_str().unwrap(), None).unwrap();
assert!(path.exists());
}
#[test]
fn test_plot_chromatogram_custom_config() {
let result = run_single(10);
let tmp = tempfile::NamedTempFile::new().unwrap();
let path = tmp.path().with_extension("png");
let mut config = PlotConfig::chromatogram("TFA Elution");
config.line_color = BLUE;
plot_chromatogram(&result, 10, path.to_str().unwrap(), Some(&config)).unwrap();
assert!(path.exists());
}
#[test]
fn test_plot_chromatogram_multi_png() {
let result = run_multi(10, 2);
let tmp = tempfile::NamedTempFile::new().unwrap();
let path = tmp.path().with_extension("png");
plot_chromatogram_multi(&result, 10, &["A", "B"], path.to_str().unwrap(), None).unwrap();
assert!(path.exists());
}
#[test]
fn test_plot_chromatogram_multi_svg() {
let result = run_multi(10, 2);
let tmp = tempfile::NamedTempFile::new().unwrap();
let path = tmp.path().with_extension("svg");
plot_chromatogram_multi(&result, 10, &["A", "B"], path.to_str().unwrap(), None).unwrap();
assert!(path.exists());
}
#[test]
fn test_plot_chromatogram_multi_three_species() {
let result = run_multi(10, 3);
let tmp = tempfile::NamedTempFile::new().unwrap();
let path = tmp.path().with_extension("png");
plot_chromatogram_multi(
&result,
10,
&["Ascorbic", "Erythorbic", "Citric"],
path.to_str().unwrap(),
None,
)
.unwrap();
assert!(path.exists());
}
#[test]
fn test_plot_chromatogram_multi_custom_colors() {
let result = run_multi(10, 2);
let tmp = tempfile::NamedTempFile::new().unwrap();
let path = tmp.path().with_extension("png");
let config = PlotConfig::multi_species_colors(vec![RED, BLUE]);
plot_chromatogram_multi(
&result,
10,
&["X", "Y"],
path.to_str().unwrap(),
Some(&config),
)
.unwrap();
assert!(path.exists());
}
#[test]
fn test_plot_chromatogram_envelope_png() {
let result = run_multi(10, 2);
let tmp = tempfile::NamedTempFile::new().unwrap();
let path = tmp.path().with_extension("png");
plot_chromatogram_envelope(&result, 10, &["A", "B"], path.to_str().unwrap(), None).unwrap();
assert!(path.exists());
}
#[test]
fn test_plot_chromatogram_envelope_svg() {
let result = run_multi(10, 2);
let tmp = tempfile::NamedTempFile::new().unwrap();
let path = tmp.path().with_extension("svg");
plot_chromatogram_envelope(&result, 10, &["A", "B"], path.to_str().unwrap(), None).unwrap();
assert!(path.exists());
}
#[test]
fn test_plot_chromatogram_envelope_three_species() {
let result = run_multi(10, 3);
let tmp = tempfile::NamedTempFile::new().unwrap();
let path = tmp.path().with_extension("png");
plot_chromatogram_envelope(
&result,
10,
&["Ascorbic", "Erythorbic", "Citric"],
path.to_str().unwrap(),
None,
)
.unwrap();
assert!(path.exists());
}
#[test]
fn test_plot_chromatogram_envelope_custom_colors() {
let result = run_multi(10, 2);
let tmp = tempfile::NamedTempFile::new().unwrap();
let path = tmp.path().with_extension("png");
let config = PlotConfig::multi_species_colors(vec![RED, BLUE]);
plot_chromatogram_envelope(
&result,
10,
&["X", "Y"],
path.to_str().unwrap(),
Some(&config),
)
.unwrap();
assert!(path.exists());
}
#[test]
fn test_plot_chromatograms_comparison() {
let result1 = run_single(10);
let result2 = run_single(10);
let tmp = tempfile::NamedTempFile::new().unwrap();
let path = tmp.path().with_extension("png");
plot_chromatograms_comparison(
vec![("Run 1", &result1, 10), ("Run 2", &result2, 10)],
path.to_str().unwrap(),
None,
)
.unwrap();
assert!(path.exists());
}
#[test]
fn test_envelope_is_max_of_species() {
let outlets: Vec<Vec<f64>> = vec![vec![1.0, 0.5], vec![0.8, 0.9]];
let n_steps = outlets[0].len();
let envelope: Vec<f64> = (0..n_steps)
.map(|i| {
outlets
.iter()
.map(|o| o[i])
.fold(f64::NEG_INFINITY, f64::max)
})
.collect();
assert!((envelope[0] - 1.0).abs() < 1e-12);
assert!((envelope[1] - 0.9).abs() < 1e-12);
}
#[test]
fn test_envelope_equals_single_species_when_one_species() {
let outlets: Vec<Vec<f64>> = vec![vec![0.3, 0.7, 0.5]];
let n_steps = outlets[0].len();
let envelope: Vec<f64> = (0..n_steps)
.map(|i| {
outlets
.iter()
.map(|o| o[i])
.fold(f64::NEG_INFINITY, f64::max)
})
.collect();
assert_eq!(envelope, outlets[0]);
}
#[test]
fn test_plot_chromatogram_enveloppe_rendered() {
let result = run_multi(10, 3);
let tmp = tempfile::NamedTempFile::new().unwrap();
let path = tmp.path().with_extension("png");
plot_chromatogram_envelope(
&result,
10,
&["Ascorbic", "Erythorbic", "Citric"],
path.to_str().unwrap(),
None,
)
.unwrap();
assert!(path.exists());
}
#[test]
fn test_cumulative_is_sum_of_species() {
let outlets: Vec<Vec<f64>> = vec![vec![1.0, 0.5], vec![0.8, 0.9]];
let n_steps = outlets[0].len();
let cumulative: Vec<f64> = (0..n_steps)
.map(|i| outlets.iter().map(|o| o[i]).sum::<f64>())
.collect();
assert!(
(cumulative[0] - 1.8).abs() < 1e-12,
"step 0: {}",
cumulative[0]
);
assert!(
(cumulative[1] - 1.4).abs() < 1e-12,
"step 1: {}",
cumulative[1]
);
}
#[test]
fn test_cumulative_geq_envelope() {
let outlets: Vec<Vec<f64>> = vec![vec![1.0, 0.5], vec![0.8, 0.9]];
let n_steps = outlets[0].len();
let cumulative: Vec<f64> = (0..n_steps)
.map(|i| outlets.iter().map(|o| o[i]).sum::<f64>())
.collect();
let envelope: Vec<f64> = (0..n_steps)
.map(|i| {
outlets
.iter()
.map(|o| o[i])
.fold(f64::NEG_INFINITY, f64::max)
})
.collect();
for i in 0..n_steps {
assert!(
cumulative[i] >= envelope[i] - 1e-12,
"step {i}: cumulative={} < envelope={}",
cumulative[i],
envelope[i]
);
}
}
#[test]
fn test_cumulative_equals_single_species_when_one_species() {
let outlets: Vec<Vec<f64>> = vec![vec![0.3, 0.7, 0.5]];
let n_steps = outlets[0].len();
let cumulative: Vec<f64> = (0..n_steps)
.map(|i| outlets.iter().map(|o| o[i]).sum::<f64>())
.collect();
assert_eq!(cumulative, outlets[0]);
}
#[test]
fn test_cumulative_max_geq_individual_max() {
let result = run_multi(10, 3);
let outlets = extract_multi_species_outlet(&result, 10, 3);
let n_steps = outlets[0].len();
let cumulative: Vec<f64> = (0..n_steps)
.map(|i| outlets.iter().map(|o| o[i]).sum::<f64>())
.collect();
let max_cumulative = cumulative.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
let max_individual = outlets
.iter()
.flat_map(|o| o.iter())
.cloned()
.fold(f64::NEG_INFINITY, f64::max);
assert!(
max_cumulative >= max_individual - 1e-12,
"max cumulative ({max_cumulative}) < max individual ({max_individual})"
);
}
#[test]
fn test_plot_chromatogram_multi_cumulative_rendered() {
let result = run_multi(10, 3);
let tmp = tempfile::NamedTempFile::new().unwrap();
let path = tmp.path().with_extension("png");
plot_chromatogram_multi(
&result,
10,
&["Ascorbic", "Erythorbic", "Citric"],
path.to_str().unwrap(),
None,
)
.unwrap();
assert!(path.exists());
}
#[test]
fn test_plot_chromatograms_comparison_empty_returns_error() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let path = tmp.path().with_extension("png");
let err = plot_chromatograms_comparison(vec![], path.to_str().unwrap(), None);
assert!(err.is_err());
}
}