use crate::models::TemporalInjection;
use crate::physics::{
ExportError, Exportable, PhysicalData, PhysicalModel, PhysicalQuantity, PhysicalState,
outlet_data, sample_indices,
};
use nalgebra::DVector;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value, json};
use std::collections::HashMap;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct LangmuirSingle {
lambda: f64,
langmuir_k: f64,
port_number: f64,
column_length: f64,
n_points: usize,
dz: f64,
fe: f64,
ue: f64,
injection: TemporalInjection,
}
impl LangmuirSingle {
#[allow(clippy::too_many_arguments)]
pub fn new(
lambda: f64,
langmuir_k: f64,
port_number: f64,
porosity: f64,
velocity: f64,
column_length: f64,
spatial_points: usize,
injection: TemporalInjection,
) -> Self {
assert!(
porosity > 0.0 && porosity < 1.0,
"Porosity must be in ]0,1[, got {}",
porosity
);
assert!(
column_length > 0.0,
"Column length must be positive, got {}",
column_length
);
assert!(
spatial_points >= 2,
"Need at least 2 spatial points, got {}",
spatial_points
);
let fe = (1.0 - porosity) / porosity;
let ue = velocity / porosity;
let dz = column_length / (spatial_points as f64);
Self {
lambda,
langmuir_k,
port_number,
column_length,
n_points: spatial_points,
dz,
fe,
ue,
injection,
}
}
pub fn from_domain(
column: &crate::domain::Column,
mobile_phase: &crate::domain::MobilePhase,
lambda: f64,
langmuir_k: f64,
port_number: f64,
injection: TemporalInjection,
) -> Self {
Self::new(
lambda,
langmuir_k,
port_number,
column.porosity,
mobile_phase.velocity,
column.column_length,
column.n_points,
injection,
)
}
pub fn injection(&self) -> &TemporalInjection {
&self.injection
}
pub fn set_injection(&mut self, injection: TemporalInjection) {
self.injection = injection;
}
pub fn spatial_points(&self) -> usize {
self.n_points
}
pub fn column_length(&self) -> f64 {
self.column_length
}
#[inline]
fn derivative_isotherm(&self, concentration: f64) -> f64 {
let denom = 1.0 + self.langmuir_k * concentration;
self.lambda + (self.port_number * self.langmuir_k) / (denom * denom)
}
#[inline]
fn propagation_factor(&self, concentration: f64) -> f64 {
let deriv = self.derivative_isotherm(concentration);
1.0 / (1.0 + self.fe * deriv)
}
}
#[typetag::serde]
impl PhysicalModel for LangmuirSingle {
fn points(&self) -> usize {
self.n_points
}
fn compute_physics(
&self,
state: &PhysicalState,
ctx: &crate::physics::context::ComputeContext,
) -> PhysicalState {
let t = ctx.time();
let c_inlet = self.injection.evaluate(t);
let c_data = state
.get(PhysicalQuantity::Concentration)
.expect("Concentration is required");
let c_profile = c_data.as_vector();
assert_eq!(
c_profile.len(),
self.n_points,
"Concentration profile size {} vs points discretization {}",
c_profile.len(),
self.n_points
);
let mut dc_dt = DVector::zeros(self.n_points);
for n in 0..self.n_points {
let c_n = c_profile[n];
let sigma = self.propagation_factor(c_n);
let dc_dz = if n > 0 {
(c_n - c_profile[n - 1]) / self.dz
} else {
(c_n - c_inlet) / self.dz
};
dc_dt[n] = -sigma * self.ue * dc_dz;
}
PhysicalState::new(PhysicalQuantity::Concentration, PhysicalData::Vector(dc_dt))
}
fn setup_initial_state(&self) -> PhysicalState {
PhysicalState::new(
PhysicalQuantity::Concentration,
PhysicalData::Vector(DVector::zeros(self.n_points)),
)
}
fn name(&self) -> &str {
"Langmuir single specie with temporal injection"
}
fn description(&self) -> Option<&str> {
Some(
"Using Langmuir isotherm with time varying inlet BC. \
Read from Physical State metadata.",
)
}
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(inj.clone());
}
Ok(())
}
}
impl Exportable for LangmuirSingle {
fn to_map(
&self,
time_points: &[f64],
trajectory: &[crate::physics::PhysicalState],
metadata: &HashMap<String, String>,
) -> Map<String, Value> {
let mut root = Map::new();
let mut meta: Map<String, Value> = [
("model", Value::String(self.name().into())),
("lambda", Value::from(self.lambda)),
("langmuir_k", Value::from(self.langmuir_k)),
("port_number", Value::from(self.port_number)),
("column_length", Value::from(self.column_length)),
("n_points", Value::from(self.n_points as u64)),
("fe", Value::from(self.fe)),
("ue", Value::from(self.ue)),
]
.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 concentrations: Vec<Value> = indices
.iter()
.map(|&i| {
let c = outlet_data(PhysicalQuantity::Concentration, trajectory, i);
Value::from(*c.first().unwrap_or(&0.0))
})
.collect();
root.insert(
"data".into(),
json!({
"time_points": tp_values,
"profiles": {
"species_0": { "Concentration": concentrations }
}
}),
);
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 lambda = get_f64!("lambda");
let langmuir_k = get_f64!("langmuir_k");
let port_number = get_f64!("port_number");
let column_length = get_f64!("column_length");
let fe = get_f64!("fe");
let ue = get_f64!("ue");
let n_points = get_usize!("n_points");
let porosity = 1.0 / (1.0 + fe);
let velocity = ue * porosity;
Ok(LangmuirSingle::new(
lambda,
langmuir_k,
port_number,
porosity,
velocity,
column_length,
n_points,
TemporalInjection::none(),
))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::TemporalInjection;
fn create_langmuir_single() -> LangmuirSingle {
let temporal = TemporalInjection::gaussian(10.0, 2.0, 1.0);
LangmuirSingle::new(1.2, 0.4, 2.0, 0.4, 0.001, 0.25, 100, temporal)
}
#[test]
fn test_create_langmuir_single() {
let model = create_langmuir_single();
assert_eq!(model.points(), 100);
assert_eq!(model.spatial_points(), 100);
}
#[test]
fn test_read_time_from_metadata() {
let model = create_langmuir_single();
let state = model.setup_initial_state();
let ctx = crate::physics::ComputeContext::new(10.0, 0.0);
let physics = model.compute_physics(&state, &ctx);
let dc_dt = physics
.get(PhysicalQuantity::Concentration)
.unwrap()
.as_vector();
assert!(dc_dt[0].abs() > 0.0, "Inlet should show temporal effect");
}
#[test]
fn test_works_without_metadata() {
let model = create_langmuir_single();
let state = model.setup_initial_state();
let ctx = crate::physics::ComputeContext::new(0.0, 0.0);
let physics = model.compute_physics(&state, &ctx);
let dc_dt = physics
.get(PhysicalQuantity::Concentration)
.unwrap()
.as_vector();
assert!(dc_dt[0].abs() < 0.01, "At t=0, injection should be minimal");
}
#[test]
fn test_different_times_different_physics() {
let model = create_langmuir_single();
let state = model.setup_initial_state();
let ctx_0 = crate::physics::ComputeContext::new(0.0, 0.0);
let physics_0 = model.compute_physics(&state, &ctx_0);
let dc_dt_0 = physics_0
.get(PhysicalQuantity::Concentration)
.unwrap()
.as_vector();
let ctx_10 = crate::physics::ComputeContext::new(10.0, 0.0);
let physics_10 = model.compute_physics(&state, &ctx_10);
let dc_dt_10 = physics_10
.get(PhysicalQuantity::Concentration)
.unwrap()
.as_vector();
assert!(
dc_dt_10[0].abs() > dc_dt_0[0].abs(),
"Inlet effect should be stronger at peak time"
);
}
#[test]
fn test_name() {
let model = create_langmuir_single();
let name = model.name();
assert_eq!(
name,
String::from("Langmuir single specie with temporal injection")
);
}
#[test]
fn test_description() {
let model = create_langmuir_single();
let description = model.description().unwrap();
assert_eq!(
description,
String::from(
"Using Langmuir isotherm with time varying inlet BC. \
Read from Physical State metadata."
)
);
}
#[test]
#[should_panic(expected = "Porosity must be in ]0,1[")]
fn test_invalid_porosity() {
let injection = TemporalInjection::none();
LangmuirSingle::new(1.2, 0.4, 2.0, 1.5, 0.001, 0.25, 100, injection);
}
#[test]
#[should_panic(expected = "Need at least 2 spatial points")]
fn test_invalid_points() {
let injection = TemporalInjection::none();
LangmuirSingle::new(1.2, 0.4, 2.0, 0.4, 0.001, 0.25, 1, injection);
}
#[test]
fn test_set_injection_replaces_profile() {
let mut model = LangmuirSingle::new(
1.2,
0.4,
2.0,
0.4,
0.001,
0.25,
100,
TemporalInjection::none(),
);
assert!((model.injection().evaluate(5.0)).abs() < 1e-15);
model.set_injection(TemporalInjection::dirac(5.0, 0.1));
assert!(model.injection().evaluate(5.0) > 0.0);
}
#[test]
fn test_set_injection_does_not_alter_physical_params() {
let mut model = LangmuirSingle::new(
1.2,
0.4,
2.0,
0.4,
0.001,
0.25,
100,
TemporalInjection::none(),
);
model.set_injection(TemporalInjection::gaussian(10.0, 3.0, 0.1));
assert_eq!(model.spatial_points(), 100);
assert!((model.column_length() - 0.25).abs() < 1e-15);
}
fn make_trajectory(model: &LangmuirSingle) -> (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_metadata_keys() {
let model = create_langmuir_single();
let (tp, traj) = make_trajectory(&model);
let map = model.to_map(&tp, &traj, &HashMap::new());
let meta = map["metadata"].as_object().unwrap();
for key in &[
"model",
"lambda",
"langmuir_k",
"port_number",
"column_length",
"n_points",
"fe",
"ue",
] {
assert!(meta.contains_key(*key), "Missing metadata key: {key}");
}
assert!((meta["lambda"].as_f64().unwrap() - 1.2).abs() < 1e-12);
assert!((meta["langmuir_k"].as_f64().unwrap() - 0.4).abs() < 1e-12);
assert_eq!(meta["n_points"].as_u64().unwrap(), 100);
}
#[test]
fn test_to_map_data_structure() {
let model = create_langmuir_single();
let (tp, traj) = make_trajectory(&model);
let map = model.to_map(&tp, &traj, &HashMap::new());
let data = map["data"].as_object().unwrap();
assert!(
data.contains_key("time_points"),
"time_points block missing"
);
assert!(data.contains_key("profiles"), "profiles block missing");
let profiles = data["profiles"].as_object().unwrap();
assert!(
profiles.contains_key("species_0"),
"species_0 block missing"
);
let conc = profiles["species_0"]["Concentration"].as_array().unwrap();
assert_eq!(
conc.len(),
tp.len(),
"Concentration length must match time_points"
);
}
#[test]
fn test_to_map_solver_metadata_merged_but_not_overriding() {
let model = create_langmuir_single();
let (tp, traj) = make_trajectory(&model);
let mut solver_meta = HashMap::new();
solver_meta.insert("solver".to_string(), "RK4".to_string());
solver_meta.insert("lambda".to_string(), "999.0".to_string());
let map = model.to_map(&tp, &traj, &solver_meta);
let meta = map["metadata"].as_object().unwrap();
assert_eq!(
meta["solver"].as_str().unwrap(),
"RK4",
"solver key must be merged"
);
assert!(
(meta["lambda"].as_f64().unwrap() - 1.2).abs() < 1e-12,
"model lambda must take precedence over solver metadata"
);
}
#[test]
fn test_from_map_round_trip() {
let original = create_langmuir_single();
let (tp, traj) = make_trajectory(&original);
let map = original.to_map(&tp, &traj, &HashMap::new());
let reconstructed = LangmuirSingle::from_map(map).expect("from_map must succeed");
assert_eq!(reconstructed.spatial_points(), original.spatial_points());
assert!((reconstructed.column_length() - original.column_length()).abs() < 1e-12);
let map2 = reconstructed.to_map(&tp, &traj, &HashMap::new());
let meta2 = map2["metadata"].as_object().unwrap();
assert!((meta2["lambda"].as_f64().unwrap() - 1.2).abs() < 1e-12);
assert!((meta2["langmuir_k"].as_f64().unwrap() - 0.4).abs() < 1e-12);
assert!((meta2["fe"].as_f64().unwrap() - 1.5).abs() < 1e-9);
assert!((meta2["ue"].as_f64().unwrap() - 0.0025).abs() < 1e-12);
}
#[test]
fn test_from_map_missing_key() {
use serde_json::json;
let partial = json!({"metadata": {"langmuir_k": 0.4}})
.as_object()
.cloned()
.unwrap();
assert!(
matches!(
LangmuirSingle::from_map(partial),
Err(ExportError::MissingKey(_))
),
"Missing 'lambda' must yield MissingKey"
);
assert!(
matches!(
LangmuirSingle::from_map(serde_json::Map::new()),
Err(ExportError::MissingKey(_))
),
"Absent metadata block 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 injection = TemporalInjection::gaussian(10.0, 2.0, 0.1);
let model = LangmuirSingle::from_domain(&column, &mobile_phase, 1.2, 0.4, 2.0, injection);
assert!((model.column_length() - 0.25).abs() < 1e-12);
assert_eq!(model.spatial_points(), 100);
let reference = LangmuirSingle::new(
1.2,
0.4,
2.0,
0.4,
1e-4,
0.25,
100,
TemporalInjection::gaussian(10.0, 2.0, 0.1),
);
assert!((model.column_length() - reference.column_length()).abs() < 1e-12);
assert_eq!(model.spatial_points(), reference.spatial_points());
}
#[test]
fn test_from_domain_propagates_column_validation() {
use crate::domain::{Column, MobilePhase};
assert!(Column::new(0.0, 100, 0.4, None).is_err());
assert!(Column::new(0.25, 1, 0.4, None).is_err());
assert!(Column::new(0.25, 100, 1.5, None).is_err());
let column = Column::new(0.25, 100, 0.4, None).unwrap();
let mp = MobilePhase::new(1e-4, None).unwrap();
let model =
LangmuirSingle::from_domain(&column, &mp, 1.2, 0.4, 2.0, TemporalInjection::none());
assert_eq!(model.spatial_points(), 100);
}
}