use nalgebra::{DMatrix, DVector};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value, json};
use std::collections::HashMap;
use crate::models::injection::TemporalInjection;
use crate::physics::{
ExportError, Exportable, PhysicalData, PhysicalModel, PhysicalQuantity, PhysicalState,
outlet_data, sample_indices,
};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SpeciesParams {
pub name: String,
pub lambda: f64,
pub langmuir_k: f64,
pub port_number: u32,
pub injection: TemporalInjection,
}
impl SpeciesParams {
pub fn new(
name: impl Into<String>,
lambda: f64,
langmuir_k: f64,
port_number: u32,
injection: TemporalInjection,
) -> Self {
Self {
name: name.into(),
lambda,
langmuir_k,
port_number,
injection,
}
}
pub fn validate(&self) -> Result<(), String> {
if self.lambda < 0.0 {
return Err(format!(
"Species '{}' : lambda must be >= 0, got {}",
self.name, self.lambda
));
}
if self.langmuir_k <= 0.0 {
return Err(format!(
"Species '{}' : Langmuir K̃ must be > 0, got {}",
self.name, self.langmuir_k
));
}
if self.port_number == 0 {
return Err(format!(
"Species '{}' : Port number must be > 0 (competitiveness), got {}",
self.name, self.port_number
));
}
Ok(())
}
}
#[derive(Debug, Serialize, Deserialize)]
#[allow(dead_code)]
pub struct LangmuirMulti {
species: Vec<SpeciesParams>,
n_points: usize,
porosity: f64,
velocity: f64,
column_length: f64,
dz: f64,
fe: f64,
ue: f64,
stationary_fraction: f64,
}
impl LangmuirMulti {
pub fn new(
species: Vec<SpeciesParams>,
n_points: usize,
porosity: f64,
velocity: f64,
column_length: f64,
) -> Result<Self, String> {
if species.is_empty() {
return Err("Langmuir multi species must have at least one species.".to_string());
}
for sp in &species {
sp.validate()?;
}
let mut seen = std::collections::HashSet::new();
for sp in &species {
if !seen.insert(&sp.name) {
return Err(format!(
"Duplicate speciees name '{}' in intial species list",
&sp.name
));
}
}
if n_points < 2 {
return Err(format!(
"number of points must have at least two points, got {n_points}."
));
}
if porosity <= 0.0 || porosity >= 1.0 {
return Err(format!("porosity must be in ]0, 1[, got {porosity}."));
}
if velocity <= 0.0 {
return Err(format!(
"velocity must be strictly positive, got {velocity}."
));
}
if column_length <= 0.0 {
return Err(format!(
"column length must be strictly positive, got {column_length}."
));
}
let dz = column_length / n_points as f64;
let fe = (1.0 - porosity) / porosity;
let ue = velocity / porosity;
let stationary_fraction = 1.0 - porosity;
Ok(Self {
species,
n_points,
porosity,
velocity,
column_length,
dz,
fe,
ue,
stationary_fraction,
})
}
pub fn from_domain(
column: &crate::domain::Column,
mobile_phase: &crate::domain::MobilePhase,
species: Vec<SpeciesParams>,
) -> Result<Self, String> {
Self::new(
species,
column.n_points,
column.porosity,
mobile_phase.velocity,
column.column_length,
)
}
pub fn add_species(&mut self, species: SpeciesParams) -> Result<(), String> {
species.validate()?;
let names: std::collections::HashSet<&str> =
self.species.iter().map(|s| s.name.as_str()).collect();
if names.contains(species.name.as_str()) {
return Err(format!(
"Species '{}' already exists in this model",
species.name
));
}
self.species.push(species);
Ok(())
}
pub fn set_injection_all(&mut self, injection: TemporalInjection) {
for sp in &mut self.species {
sp.injection = injection.clone();
}
}
pub fn set_injection_for(
&mut self,
species: &str,
injection: TemporalInjection,
) -> Result<(), String> {
match self.species.iter_mut().find(|sp| sp.name == species) {
Some(sp) => {
sp.injection = injection;
Ok(())
}
None => Err(format!("Species '{species}' not found in model")),
}
}
pub fn n_species(&self) -> usize {
self.species.len()
}
pub fn porosity(&self) -> f64 {
self.porosity
}
pub fn velocity(&self) -> f64 {
self.velocity
}
pub fn column_length(&self) -> f64 {
self.column_length
}
pub fn spatial_points(&self) -> usize {
self.n_points
}
pub fn species_params(&self) -> &[SpeciesParams] {
&self.species
}
pub fn species_names(&self) -> Vec<&str> {
self.species.iter().map(|s| s.name.as_str()).collect()
}
pub(crate) fn jacobian(&self, c: &[f64]) -> DMatrix<f64> {
let n = self.n_species();
debug_assert_eq!(
n,
self.species.len(),
"Jacobian: c.len()={} != n_species={}",
c.len(),
n
);
let sum_kc: f64 = self
.species
.iter()
.zip(c.iter())
.map(|(sp, &ck)| sp.langmuir_k * ck)
.sum();
let denom = 1.0 + sum_kc;
let denom_exp2 = denom * denom;
let mut jacobian = DMatrix::zeros(n, n);
for i in 0..n {
let sp_i = &self.species[i];
let n_bar_i = self.stationary_fraction * sp_i.port_number as f64;
for j in 0..n {
jacobian[(i, j)] = if i == j {
let num = n_bar_i * sp_i.langmuir_k * (denom - sp_i.langmuir_k * c[i]);
sp_i.lambda + num / denom_exp2
} else {
let num = -n_bar_i * sp_i.langmuir_k * self.species[j].langmuir_k * c[i];
num / denom_exp2
}
}
}
jacobian
}
pub(crate) fn inverse_propagation(&self, c: &[f64]) -> Result<DMatrix<f64>, String> {
let n = self.n_species();
let jacobian = self.jacobian(c);
let mat = DMatrix::identity(n, n) + self.fe * jacobian;
mat.try_inverse().ok_or_else(|| {
format!(
"Matrix (I + Fe * J) is singular at concentration {:?}. \
Please check langmuir K and port number values",
c
)
})
}
}
#[typetag::serde]
impl PhysicalModel for LangmuirMulti {
fn points(&self) -> usize {
self.n_points
}
fn compute_physics(
&self,
state: &PhysicalState,
ctx: &crate::physics::context::ComputeContext,
) -> PhysicalState {
let n_species = self.n_species();
let t = ctx.time();
let c_matrix = state
.get(PhysicalQuantity::Concentration)
.expect("LangmuirMulti::compute_physics: state must contain Concentration")
.as_matrix()
.clone_owned();
debug_assert_eq!(
(c_matrix.nrows(), c_matrix.ncols()),
(self.n_points, n_species),
"State matrix shape [{}, {}] expected, got [{}, {}]",
self.n_points,
n_species,
c_matrix.nrows(),
c_matrix.ncols()
);
let compute_row = |i: usize| -> Vec<f64> {
let c_at_i: Vec<f64> = (0..n_species).map(|k| c_matrix[(i, k)]).collect();
let inv = match self.inverse_propagation(&c_at_i) {
Ok(m_inv) => m_inv,
Err(e) => {
log::warn!("LangmuirMulti at point {i}: {e}. Using identity fallback.");
DMatrix::identity(n_species, n_species)
}
};
let mut grad = DVector::zeros(n_species);
for k in 0..n_species {
let c_cur = c_matrix[(i, k)];
grad[k] = if i > 0 {
(c_cur - c_matrix[(i - 1, k)]) / self.dz
} else {
let c_upstream = self.species[k].injection.evaluate(t);
(c_cur - c_upstream) / self.dz
};
}
let dv: DVector<f64> = inv * grad;
(0..n_species).map(|k| -self.ue * dv[k]).collect()
};
let mut dc_dt = DMatrix::zeros(self.n_points, n_species);
if (self.n_points * self.n_species()) > crate::solver::parallel_threshold() {
#[cfg(feature = "parallel")]
{
use rayon::prelude::*;
let rows: Vec<Vec<f64>> =
(0..n_species).into_par_iter().map(&compute_row).collect();
for (i, row) in rows.into_iter().enumerate() {
for k in 0..n_species {
dc_dt[(i, k)] = row[k];
}
}
}
#[cfg(not(feature = "parallel"))]
{
for i in 0..self.n_points {
let row = compute_row(i);
for k in 0..n_species {
dc_dt[(i, k)] = row[k];
}
}
}
} else {
for i in 0..self.n_points {
let row = compute_row(i);
for k in 0..n_species {
dc_dt[(i, k)] = row[k];
}
}
}
PhysicalState::new(
PhysicalQuantity::Concentration,
PhysicalData::from_matrix(dc_dt),
)
}
fn setup_initial_state(&self) -> PhysicalState {
PhysicalState::new(
PhysicalQuantity::Concentration,
PhysicalData::from_matrix(DMatrix::zeros(self.n_points, self.n_species())),
)
}
fn name(&self) -> &str {
"Langmuir Multi-Species"
}
fn description(&self) -> Option<&str> {
Some(
"Competitive Langmuir adsorption model for n species \
with upwind spatial discretisation and matrix Jacobian inversion.",
)
}
fn set_injections(
&mut self,
injections: &HashMap<Option<String>, TemporalInjection>,
) -> Result<(), String> {
let default_key: Option<String> = None;
if let Some(inj) = injections.get(&default_key) {
self.set_injection_all(inj.clone());
}
for (key, inj) in injections {
if let Some(name) = key {
self.set_injection_for(name, inj.clone())?;
}
}
Ok(())
}
}
impl Exportable for LangmuirMulti {
fn to_map(
&self,
time_points: &[f64],
trajectory: &[crate::physics::PhysicalState],
metadata: &HashMap<String, String>,
) -> Map<String, Value> {
let mut root = Map::new();
let species_meta: Vec<Value> = self
.species_params()
.iter()
.map(|s| {
json!({
"name": s.name,
"lambda": s.lambda,
"langmuir_k": s.langmuir_k,
"port_number": s.port_number,
})
})
.collect();
let mut meta: Map<String, Value> = [
("model", Value::String(self.name().into())),
("n_species", Value::from(self.n_species() as u64)),
("nz", Value::from(self.spatial_points() as u64)),
("porosity", Value::from(self.porosity())),
("velocity", Value::from(self.velocity())),
("length", Value::from(self.column_length())),
("species", Value::Array(species_meta)),
]
.into_iter()
.map(|(k, v)| (k.to_string(), v))
.collect();
for (k, v) in metadata {
meta.entry(k.clone())
.or_insert_with(|| Value::String(v.clone()));
}
root.insert("metadata".into(), Value::Object(meta));
let indices = sample_indices(time_points.len(), None);
let tp_values: Vec<Value> = indices
.iter()
.map(|&i| Value::from(time_points[i]))
.collect();
let names = self.species_names();
let n = names.len();
let mut per_species: Vec<Vec<Value>> = vec![Vec::with_capacity(indices.len()); n];
for &i in &indices {
let outlet = outlet_data(PhysicalQuantity::Concentration, trajectory, i);
for (s, col) in per_species.iter_mut().enumerate() {
col.push(Value::from(*outlet.get(s).unwrap_or(&0.0)));
}
}
let mut profiles = Map::new();
for (s, conc_vals) in per_species.into_iter().enumerate() {
let mut block = Map::new();
block.insert("name".into(), Value::String(names[s].to_string()));
block.insert("Concentration".into(), Value::Array(conc_vals));
profiles.insert(format!("species_{s}"), Value::Object(block));
}
profiles.insert("global".into(), Value::Object(Map::new()));
root.insert(
"data".into(),
json!({
"time_points": tp_values,
"profiles": profiles,
}),
);
root
}
fn from_map(map: Map<String, Value>) -> Result<Self, ExportError>
where
Self: Sized,
{
let meta = map
.get("metadata")
.and_then(|v| v.as_object())
.ok_or_else(|| ExportError::MissingKey("metadata".into()))?;
macro_rules! get_f64 {
($key:expr) => {
meta.get($key)
.and_then(|v| v.as_f64())
.ok_or_else(|| ExportError::MissingKey($key.into()))?
};
}
macro_rules! get_usize {
($key:expr) => {
meta.get($key)
.and_then(|v| v.as_u64())
.map(|v| v as usize)
.ok_or_else(|| ExportError::MissingKey($key.into()))?
};
}
let nz = get_usize!("nz");
let porosity = get_f64!("porosity");
let velocity = get_f64!("velocity");
let length = get_f64!("length");
let species_arr = meta
.get("species")
.and_then(|v| v.as_array())
.ok_or_else(|| ExportError::MissingKey("metadata.species".into()))?;
if species_arr.is_empty() {
return Err(ExportError::SpeciesCountMismatch {
expected: 1,
got: 0,
});
}
let mut params: Vec<SpeciesParams> = Vec::with_capacity(species_arr.len());
for (i, s) in species_arr.iter().enumerate() {
let obj = s.as_object().ok_or_else(|| ExportError::InvalidValue {
key: format!("metadata.species[{i}]"),
reason: "expected object".into(),
})?;
macro_rules! field_f64 {
($key:expr) => {
obj.get($key).and_then(|v| v.as_f64()).ok_or_else(|| {
ExportError::MissingKey(format!("metadata.species[{i}].{}", $key))
})?
};
}
macro_rules! field_u32 {
($key:expr) => {
obj.get($key)
.and_then(|v| v.as_u64())
.map(|v| v as u32)
.ok_or_else(|| {
ExportError::MissingKey(format!("metadata.species[{i}].{}", $key))
})?
};
}
let name = obj
.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| ExportError::MissingKey(format!("metadata.species[{i}].name")))?
.to_string();
params.push(SpeciesParams::new(
&name,
field_f64!("lambda"),
field_f64!("langmuir_k"),
field_u32!("port_number"),
TemporalInjection::none(),
));
}
let first = params.remove(0);
let mut model =
LangmuirMulti::new(vec![first], nz, porosity, velocity, length).map_err(|e| {
ExportError::InvalidValue {
key: "metadata".into(),
reason: e,
}
})?;
for sp in params {
model
.add_species(sp)
.map_err(|e| ExportError::InvalidValue {
key: "metadata.species".into(),
reason: e,
})?;
}
Ok(model)
}
}
#[cfg(test)]
mod tests {
use super::*;
use approx::assert_relative_eq;
fn inj() -> TemporalInjection {
TemporalInjection::dirac(0.0, 1e-3) }
fn species(name: &str, lambda: f64, k: f64, n: u32) -> SpeciesParams {
SpeciesParams::new(name, lambda, k, n, inj())
}
fn single_model() -> LangmuirMulti {
LangmuirMulti::new(vec![species("A", 1.0, 0.5, 1)], 100, 0.4, 0.001, 0.25).unwrap()
}
fn two_species_model() -> LangmuirMulti {
let mut m = single_model();
m.add_species(species("B", 1.0, 2.0, 1)).unwrap();
m
}
#[test]
fn test_species_lambda_zero_is_valid() {
assert!(species("X", 0.0, 1.0, 1).validate().is_ok());
}
#[test]
fn test_species_lambda_negative_is_invalid() {
let err = species("X", -0.1, 1.0, 1).validate().unwrap_err();
assert!(err.contains("lambda") && err.contains('X'));
}
#[test]
fn test_species_k_zero_is_invalid() {
let err = species("X", 1.0, 0.0, 1).validate().unwrap_err();
assert!(err.contains("langmuir_k") || err.contains("K̃"));
}
#[test]
fn test_species_k_negative_is_invalid() {
assert!(species("X", 1.0, -1.0, 1).validate().is_err());
}
#[test]
fn test_species_port_number_zero_is_invalid() {
let err = species("X", 1.0, 1.0, 0).validate().unwrap_err();
assert!(err.contains("Port number") || err.contains('N'));
}
#[test]
fn test_new_valid_model() {
let m = single_model();
assert_eq!(m.n_species(), 1);
assert_eq!(m.n_points, 100);
}
#[test]
fn test_new_rejects_n_points_less_than_2() {
assert!(
LangmuirMulti::new(vec![species("A", 1.0, 0.5, 1)], 1, 0.4, 0.001, 0.25)
.unwrap_err()
.contains("number of points")
);
}
#[test]
fn test_new_rejects_porosity_zero() {
assert!(
LangmuirMulti::new(vec![species("A", 1.0, 0.5, 1)], 100, 0.0, 0.001, 0.25)
.unwrap_err()
.contains("porosity")
);
}
#[test]
fn test_new_rejects_porosity_one() {
assert!(
LangmuirMulti::new(vec![species("A", 1.0, 0.5, 1)], 100, 1.0, 0.001, 0.25)
.unwrap_err()
.contains("porosity")
);
}
#[test]
fn test_new_rejects_zero_velocity() {
assert!(
LangmuirMulti::new(vec![species("A", 1.0, 0.5, 1)], 100, 0.4, 0.0, 0.25)
.unwrap_err()
.contains("velocity")
);
}
#[test]
fn test_new_rejects_zero_length() {
assert!(
LangmuirMulti::new(vec![species("A", 1.0, 0.5, 1)], 100, 0.4, 0.001, 0.0)
.unwrap_err()
.contains("column length")
);
}
#[test]
fn test_new_precomputes_derived_quantities() {
let m = LangmuirMulti::new(vec![species("A", 1.0, 0.5, 1)], 100, 0.4, 0.001, 0.25).unwrap();
assert_relative_eq!(m.fe, 1.5, epsilon = 1e-12);
assert_relative_eq!(m.ue, 0.0025, epsilon = 1e-12);
assert_relative_eq!(m.dz, 0.25 / 100.0, epsilon = 1e-12);
assert_relative_eq!(m.stationary_fraction, 0.6, epsilon = 1e-12);
}
#[test]
fn test_add_species_increases_count() {
let mut m = single_model();
m.add_species(species("B", 1.0, 2.0, 1)).unwrap();
assert_eq!(m.n_species(), 2);
m.add_species(species("C", 1.0, 5.0, 1)).unwrap();
assert_eq!(m.n_species(), 3);
}
#[test]
fn test_add_species_rejects_duplicate_name() {
let mut m = single_model();
let err = m.add_species(species("A", 1.0, 2.0, 1)).unwrap_err();
assert!(err.contains("already exists") && err.contains('A'));
}
#[test]
fn test_add_species_rejects_invalid_params_and_leaves_model_unchanged() {
let mut m = single_model();
assert!(m.add_species(species("B", -1.0, 2.0, 1)).is_err());
assert_eq!(m.n_species(), 1); }
#[test]
fn test_species_names_order() {
let mut m = single_model();
m.add_species(species("B", 1.0, 2.0, 1)).unwrap();
m.add_species(species("C", 1.0, 5.0, 1)).unwrap();
assert_eq!(m.species_names(), vec!["A", "B", "C"]);
}
#[test]
fn test_jacobian_1x1_at_zero_concentration() {
let jac = single_model().jacobian(&[0.0]);
assert_eq!((jac.nrows(), jac.ncols()), (1, 1));
assert_relative_eq!(jac[(0, 0)], 1.0 + 0.6 * 0.5, epsilon = 1e-12);
}
#[test]
fn test_jacobian_1x1_with_n_bar_calculation() {
let sp = SpeciesParams::new("T", 1.0, 0.5, 2, inj());
let m = LangmuirMulti::new(vec![sp], 50, 0.4, 0.001, 0.25).unwrap();
assert_relative_eq!(m.jacobian(&[0.0])[(0, 0)], 1.0 + 1.2 * 0.5, epsilon = 1e-12);
}
#[test]
fn test_jacobian_2x2_shape() {
let jac = two_species_model().jacobian(&[1e-8, 1e-8]);
assert_eq!((jac.nrows(), jac.ncols()), (2, 2));
}
#[test]
fn test_jacobian_diagonal_positive() {
let jac = two_species_model().jacobian(&[1e-4, 1e-4]);
assert!(jac[(0, 0)] > 0.0 && jac[(1, 1)] > 0.0);
}
#[test]
fn test_jacobian_off_diagonal_negative() {
let jac = two_species_model().jacobian(&[1e-4, 1e-4]);
assert!(jac[(0, 1)] <= 0.0 && jac[(1, 0)] <= 0.0);
}
#[test]
fn test_jacobian_off_diagonal_zero_when_ci_zero() {
let jac = two_species_model().jacobian(&[0.0, 1e-3]);
assert_relative_eq!(jac[(0, 1)], 0.0, epsilon = 1e-15);
}
#[test]
fn test_jacobian_all_finite() {
let jac = two_species_model().jacobian(&[1e-6, 2e-6]);
assert!(jac.iter().all(|v| v.is_finite()));
}
#[test]
fn test_jacobian_3x3_dimensions() {
let mut m = two_species_model();
m.add_species(species("C", 1.0, 5.0, 1)).unwrap();
let jac = m.jacobian(&[1e-8, 1e-8, 1e-8]);
assert_eq!((jac.nrows(), jac.ncols()), (3, 3));
}
#[test]
fn test_inverse_matrix_invertible() {
assert!(
two_species_model()
.inverse_propagation(&[1e-8, 1e-8])
.is_ok()
);
}
#[test]
fn test_inverse_matrix_dimensions() {
let inv = two_species_model()
.inverse_propagation(&[1e-8, 1e-8])
.unwrap();
assert_eq!((inv.nrows(), inv.ncols()), (2, 2));
}
#[test]
fn test_inverse_matrix_times_original_is_identity() {
let model = two_species_model();
let c = &[1e-6, 2e-6];
let mat = DMatrix::identity(2, 2) + model.fe * model.jacobian(c);
let inv = model.inverse_propagation(c).unwrap();
let prod = &mat * &inv;
for i in 0..2 {
for j in 0..2 {
let expected = if i == j { 1.0 } else { 0.0 };
assert!(
(prod[(i, j)] - expected).abs() < 1e-10,
"A·A⁻¹[{i},{j}] should be {}",
prod[(i, j)]
);
}
}
}
#[test]
fn test_initial_state_is_empty_column() {
let mat = two_species_model()
.setup_initial_state()
.get(PhysicalQuantity::Concentration)
.unwrap()
.as_matrix()
.clone_owned();
assert_eq!((mat.nrows(), mat.ncols()), (100, 2));
assert!(
mat.iter().all(|&v| v == 0.0),
"Initial state must be zero everywhere — injection handled by compute_physics"
);
}
#[test]
fn test_compute_physics_output_shape() {
let model = two_species_model();
let ctx = crate::physics::ComputeContext::new(0.0, 0.0);
let phys = model.compute_physics(&model.setup_initial_state(), &ctx);
let mat = phys
.get(PhysicalQuantity::Concentration)
.unwrap()
.as_matrix();
assert_eq!((mat.nrows(), mat.ncols()), (100, 2));
}
#[test]
fn test_compute_physics_reads_time_from_metadata() {
let mut model = LangmuirMulti::new(
vec![SpeciesParams::new(
"A",
1.0,
0.5,
1,
TemporalInjection::gaussian(10.0, 2.0, 0.1),
)],
100,
0.4,
0.001,
0.25,
)
.unwrap();
model
.add_species(SpeciesParams::new(
"B",
1.0,
2.0,
1,
TemporalInjection::gaussian(10.0, 2.0, 0.1),
))
.unwrap();
let state = model.setup_initial_state();
let ctx_0 = crate::physics::ComputeContext::new(0.0, 0.0);
let dc_t0 = model
.compute_physics(&state, &ctx_0)
.get(PhysicalQuantity::Concentration)
.unwrap()
.as_matrix()
.clone_owned();
let ctx_10 = crate::physics::ComputeContext::new(10.0, 0.0);
let dc_t10 = model
.compute_physics(&state, &ctx_10)
.get(PhysicalQuantity::Concentration)
.unwrap()
.as_matrix()
.clone_owned();
assert!(
dc_t10[(0, 0)].abs() > dc_t0[(0, 0)].abs(),
"Inlet dC/dt for A must be stronger at injection peak (t=10)"
);
assert!(
dc_t10[(0, 1)].abs() > dc_t0[(0, 1)].abs(),
"Inlet dC/dt for B must be stronger at injection peak (t=10)"
);
}
#[test]
fn test_compute_physics_defaults_to_t0_without_metadata() {
let model = two_species_model();
let ctx = crate::physics::ComputeContext::new(0.0, 0.0);
let mat = model
.compute_physics(&model.setup_initial_state(), &ctx)
.get(PhysicalQuantity::Concentration)
.unwrap()
.as_matrix()
.clone_owned();
assert!(
mat.iter().all(|v| v.is_finite()),
"compute_physics must produce finite values when metadata is absent"
);
}
#[test]
fn test_compute_physics_no_nan_or_inf() {
let model = two_species_model();
let st = model.setup_initial_state();
let ctx = crate::physics::ComputeContext::new(5.0, 0.0);
let mat = model
.compute_physics(&st, &ctx)
.get(PhysicalQuantity::Concentration)
.unwrap()
.as_matrix()
.clone_owned();
assert!(
mat.iter().all(|v| v.is_finite()),
"All dC/dt values must be finite"
);
}
#[test]
fn test_compute_physics_interior_zero_on_empty_column() {
let model = two_species_model();
let st = model.setup_initial_state();
let ctx = crate::physics::ComputeContext::new(0.0, 0.0);
let mat = model
.compute_physics(&st, &ctx)
.get(PhysicalQuantity::Concentration)
.unwrap()
.as_matrix()
.clone_owned();
for i in 1..100 {
for k in 0..2 {
assert!(
mat[(i, k)].abs() < 1e-15,
"dC/dt[{i},{k}] must be 0 for interior of empty column"
);
}
}
}
#[test]
fn test_points_returns_n_points() {
assert_eq!(single_model().points(), 100);
}
#[test]
fn test_name() {
assert_eq!(single_model().name(), "Langmuir Multi-Species");
}
#[test]
fn test_description_is_some() {
assert!(single_model().description().is_some());
}
#[test]
fn test_set_injection_all_applies_to_every_species() {
let mut model = two_species_model();
model.set_injection_all(TemporalInjection::dirac(5.0, 0.1));
for sp in model.species_params() {
assert!(
sp.injection.evaluate(5.0) > 0.0,
"Species '{}' must have non-zero injection at peak",
sp.name
);
}
}
#[test]
fn test_set_injection_all_overwrites_previous_profile() {
let mut model = two_species_model();
model.set_injection_all(TemporalInjection::gaussian(10.0, 3.0, 0.1));
model.set_injection_all(TemporalInjection::none());
for sp in model.species_params() {
assert!(
sp.injection.evaluate(10.0).abs() < 1e-15,
"Species '{}' injection must be none after overwrite",
sp.name
);
}
}
#[test]
fn test_set_injection_for_targets_single_species() {
let mut model = two_species_model();
model.set_injection_all(TemporalInjection::none());
model
.set_injection_for("B", TemporalInjection::dirac(5.0, 0.1))
.unwrap();
let sp_a = model
.species_params()
.iter()
.find(|s| s.name == "A")
.unwrap();
assert!(sp_a.injection.evaluate(5.0).abs() < 1e-15);
let sp_b = model
.species_params()
.iter()
.find(|s| s.name == "B")
.unwrap();
assert!(sp_b.injection.evaluate(5.0) > 0.0);
}
#[test]
fn test_set_injection_for_unknown_species_returns_err() {
let mut model = single_model();
let result = model.set_injection_for("Unknown", TemporalInjection::none());
assert!(result.is_err());
assert!(result.unwrap_err().contains("Unknown"));
}
#[test]
fn test_set_injection_for_does_not_alter_physical_params() {
let mut model = two_species_model();
model
.set_injection_for("A", TemporalInjection::gaussian(10.0, 3.0, 0.1))
.unwrap();
assert_eq!(model.n_species(), 2);
assert_eq!(model.spatial_points(), 100);
assert!((model.porosity() - 0.4).abs() < 1e-15);
}
fn make_trajectory(model: &LangmuirMulti) -> (Vec<f64>, Vec<PhysicalState>) {
let state = model.setup_initial_state();
let time_points = vec![0.0, 0.5, 1.0];
let trajectory = vec![state.clone(), state.clone(), state.clone()];
(time_points, trajectory)
}
#[test]
fn test_to_map_species_blocks() {
let model = two_species_model();
let (tp, traj) = make_trajectory(&model);
let map = model.to_map(&tp, &traj, &HashMap::new());
let profiles = &map["data"]["profiles"];
assert!(
profiles.get("species_0").is_some(),
"species_0 block missing"
);
assert!(
profiles.get("species_1").is_some(),
"species_1 block missing"
);
assert_eq!(profiles["species_0"]["name"].as_str().unwrap(), "A");
assert_eq!(profiles["species_1"]["name"].as_str().unwrap(), "B");
let conc0 = profiles["species_0"]["Concentration"].as_array().unwrap();
assert_eq!(
conc0.len(),
tp.len(),
"species_0 Concentration length mismatch"
);
}
#[test]
fn test_to_map_global_block_present_and_empty() {
let model = single_model();
let (tp, traj) = make_trajectory(&model);
let map = model.to_map(&tp, &traj, &HashMap::new());
let profiles = map["data"]["profiles"].as_object().unwrap();
assert!(
profiles.contains_key("global"),
"global extension block missing"
);
assert!(
profiles["global"].as_object().unwrap().is_empty(),
"global block must be empty for Matrix data"
);
}
#[test]
fn test_to_map_metadata_species_array() {
let model = two_species_model();
let (tp, traj) = make_trajectory(&model);
let map = model.to_map(&tp, &traj, &HashMap::new());
let meta = map["metadata"].as_object().unwrap();
assert_eq!(meta["n_species"].as_u64().unwrap(), 2);
let species_arr = meta["species"].as_array().unwrap();
assert_eq!(species_arr.len(), 2, "species array length mismatch");
assert_eq!(species_arr[0]["name"].as_str().unwrap(), "A");
assert_eq!(species_arr[1]["name"].as_str().unwrap(), "B");
assert!((species_arr[0]["lambda"].as_f64().unwrap() - 1.0).abs() < 1e-12);
}
#[test]
fn test_from_map_round_trip() {
let original = two_species_model();
let (tp, traj) = make_trajectory(&original);
let map = original.to_map(&tp, &traj, &HashMap::new());
let reconstructed = LangmuirMulti::from_map(map).expect("from_map must succeed");
assert_eq!(reconstructed.n_species(), original.n_species());
assert_eq!(reconstructed.spatial_points(), original.spatial_points());
assert!((reconstructed.porosity() - original.porosity()).abs() < 1e-12);
assert!((reconstructed.velocity() - original.velocity()).abs() < 1e-12);
assert!((reconstructed.column_length() - original.column_length()).abs() < 1e-12);
assert_eq!(reconstructed.species_names(), original.species_names());
}
#[test]
fn test_from_map_species_count_mismatch() {
use serde_json::json;
let map = json!({
"metadata": {
"nz": 100, "porosity": 0.4, "velocity": 0.001, "length": 0.25,
"species": []
}
})
.as_object()
.cloned()
.unwrap();
assert!(
matches!(
LangmuirMulti::from_map(map),
Err(ExportError::SpeciesCountMismatch { .. })
),
"Empty species array must yield SpeciesCountMismatch"
);
}
#[test]
fn test_from_map_missing_key() {
use serde_json::json;
let map = json!({
"metadata": {
"nz": 100, "velocity": 0.001, "length": 0.25,
"species": [{"name": "A", "lambda": 1.0, "langmuir_k": 0.5, "port_number": 1}]
}
})
.as_object()
.cloned()
.unwrap();
assert!(
matches!(
LangmuirMulti::from_map(map),
Err(ExportError::MissingKey(_))
),
"Missing 'porosity' must yield MissingKey"
);
}
#[test]
fn test_from_domain_builds_equivalent_model() {
use crate::domain::{Column, MobilePhase};
let column = Column::new(0.25, 100, 0.4, None).unwrap();
let mobile_phase = MobilePhase::new(1e-4, None).unwrap();
let species = vec![SpeciesParams::new(
"A",
1.2,
0.4,
2,
TemporalInjection::none(),
)];
let model = LangmuirMulti::from_domain(&column, &mobile_phase, species).unwrap();
assert_eq!(model.n_species(), 1);
assert!((model.column_length() - 0.25).abs() < 1e-12);
assert_eq!(model.spatial_points(), 100);
}
#[test]
fn test_from_domain_multi_species() {
use crate::domain::{Column, MobilePhase};
let column = Column::new(0.25, 100, 0.4, None).unwrap();
let mobile_phase = MobilePhase::new(1e-4, None).unwrap();
let species = vec![
SpeciesParams::new("A", 1.2, 0.4, 2, TemporalInjection::none()),
SpeciesParams::new("B", 0.8, 0.3, 2, TemporalInjection::none()),
];
let model = LangmuirMulti::from_domain(&column, &mobile_phase, species).unwrap();
assert_eq!(model.n_species(), 2);
}
#[test]
fn test_from_domain_empty_species_fails() {
use crate::domain::{Column, MobilePhase};
let column = Column::new(0.25, 100, 0.4, None).unwrap();
let mobile_phase = MobilePhase::new(1e-4, None).unwrap();
assert!(LangmuirMulti::from_domain(&column, &mobile_phase, vec![]).is_err());
}
#[test]
fn test_from_domain_duplicate_species_fails() {
use crate::domain::{Column, MobilePhase};
let column = Column::new(0.25, 100, 0.4, None).unwrap();
let mobile_phase = MobilePhase::new(1e-4, None).unwrap();
let species = vec![
SpeciesParams::new("A", 1.2, 0.4, 2, TemporalInjection::none()),
SpeciesParams::new("A", 0.8, 0.3, 2, TemporalInjection::none()),
];
assert!(LangmuirMulti::from_domain(&column, &mobile_phase, species).is_err());
}
}