use crate::context::calculator::ContextCalculator;
use crate::context::compute::ComputeContext;
use crate::context::error::OxiflowError;
use crate::context::value::ContextValue;
use crate::context::variable::ContextVariable;
use crate::model::traits::RequiresContext;
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Interpolation {
Linear,
}
#[derive(Debug)]
pub struct ExternalTabulated {
variable: ContextVariable,
data: Vec<(f64, f64)>,
interpolation: Interpolation,
}
impl ExternalTabulated {
#[cfg(feature = "hdf5")]
pub fn from_hdf5(
path: &std::path::Path,
variable: ContextVariable,
interpolation: Interpolation,
) -> Result<Self, OxiflowError> {
let group_name = match &variable {
ContextVariable::External { name } => name.as_ref(),
_ => {
return Err(OxiflowError::ExternalData(
"ExternalTabulated::from_hdf5 requires a ContextVariable::External \
(its `name` is used as the HDF5 group to read)"
.to_string(),
));
}
};
let file = hdf5::File::open(path)
.map_err(|e| OxiflowError::Persistence(format!("cannot open {path:?}: {e}")))?;
let group = file.group(group_name).map_err(|e| {
OxiflowError::Persistence(format!("no group {group_name:?} in {path:?}: {e}"))
})?;
let read_column = |name: &str| -> Result<Vec<f64>, OxiflowError> {
let ds = group.dataset(name).map_err(|e| {
OxiflowError::Persistence(format!(
"no dataset {name:?} in group {group_name:?} ({path:?}): {e}"
))
})?;
let arr = ds.read_1d::<f64>().map_err(|e| {
OxiflowError::Persistence(format!(
"cannot read dataset {name:?} in group {group_name:?} ({path:?}): {e}"
))
})?;
Ok(arr.iter().copied().collect())
};
let t = read_column("t")?;
let value = read_column("value")?;
if t.len() != value.len() {
return Err(OxiflowError::Persistence(format!(
"group {group_name:?} ({path:?}): 't' has {} points, 'value' has {} — \
must match",
t.len(),
value.len()
)));
}
let data: Vec<(f64, f64)> = t.into_iter().zip(value).collect();
Self::new(variable, data, interpolation)
}
pub fn new(
variable: ContextVariable,
data: Vec<(f64, f64)>,
interpolation: Interpolation,
) -> Result<Self, OxiflowError> {
if data.len() < 2 {
return Err(OxiflowError::ExternalData(format!(
"ExternalTabulated requires at least 2 data points, got {}",
data.len()
)));
}
for w in data.windows(2) {
if w[0].0 >= w[1].0 {
return Err(OxiflowError::ExternalData(format!(
"ExternalTabulated data must be sorted by ascending t: \
t[i]={} >= t[i+1]={}",
w[0].0, w[1].0
)));
}
}
Ok(Self {
variable,
data,
interpolation,
})
}
fn interpolate(&self, t: f64) -> f64 {
let (t_min, v_min) = self.data[0];
let (t_max, v_max) = *self.data.last().unwrap();
if t <= t_min {
return v_min;
}
if t >= t_max {
return v_max;
}
let idx = self
.data
.partition_point(|(ti, _)| *ti <= t)
.saturating_sub(1);
let (t0, v0) = self.data[idx];
let (t1, v1) = self.data[idx + 1];
match self.interpolation {
Interpolation::Linear => v0 + (v1 - v0) * (t - t0) / (t1 - t0),
#[allow(unreachable_patterns)]
_ => v0, }
}
}
impl RequiresContext for ExternalTabulated {
fn required_variables(&self) -> Vec<ContextVariable> {
vec![]
}
fn priority(&self) -> u32 {
50
}
}
impl ContextCalculator for ExternalTabulated {
fn provides(&self) -> ContextVariable {
self.variable.clone()
}
fn compute(
&self,
_state: &ContextValue,
ctx: &ComputeContext,
) -> Result<ContextValue, OxiflowError> {
let value = self.interpolate(ctx.time());
Ok(ContextValue::Scalar(value))
}
fn name(&self) -> &str {
"external_tabulated (built-in)"
}
}
#[cfg(test)]
mod tests {
use std::borrow::Cow;
use super::*;
fn var() -> ContextVariable {
ContextVariable::External {
name: Cow::Borrowed("feed"),
}
}
fn linear_data() -> Vec<(f64, f64)> {
vec![(0.0, 0.0), (1.0, 1.0), (2.0, 2.0)]
}
fn calc(data: Vec<(f64, f64)>) -> ExternalTabulated {
ExternalTabulated::new(var(), data, Interpolation::Linear).unwrap()
}
fn ctx(t: f64) -> ComputeContext {
ComputeContext::new(t, 0.01)
}
#[test]
fn new_succeeds_with_valid_data() {
assert!(ExternalTabulated::new(var(), linear_data(), Interpolation::Linear).is_ok());
}
#[test]
fn new_fails_with_single_point() {
let result = ExternalTabulated::new(var(), vec![(0.0, 1.0)], Interpolation::Linear);
assert!(matches!(result, Err(OxiflowError::ExternalData(_))));
}
#[test]
fn new_fails_with_empty_data() {
let result = ExternalTabulated::new(var(), vec![], Interpolation::Linear);
assert!(matches!(result, Err(OxiflowError::ExternalData(_))));
}
#[test]
fn new_fails_when_not_sorted() {
let result =
ExternalTabulated::new(var(), vec![(1.0, 1.0), (0.0, 0.0)], Interpolation::Linear);
assert!(matches!(result, Err(OxiflowError::ExternalData(_))));
}
#[test]
fn new_fails_on_duplicate_t() {
let result = ExternalTabulated::new(
var(),
vec![(0.0, 0.0), (0.0, 1.0), (1.0, 2.0)],
Interpolation::Linear,
);
assert!(matches!(result, Err(OxiflowError::ExternalData(_))));
}
#[test]
fn provides_configured_variable() {
let v = var();
let c = calc(linear_data());
assert_eq!(c.provides(), v);
}
#[test]
fn priority_is_fifty() {
assert_eq!(calc(linear_data()).priority(), 50);
}
#[test]
fn interpolates_at_midpoint() {
let c = calc(linear_data());
let val = c.compute(&ContextValue::Scalar(0.0), &ctx(0.5)).unwrap();
assert!((val.as_scalar().unwrap() - 0.5).abs() < 1e-10);
}
#[test]
fn interpolates_exactly_at_knot() {
let c = calc(linear_data());
let val = c.compute(&ContextValue::Scalar(0.0), &ctx(1.0)).unwrap();
assert!((val.as_scalar().unwrap() - 1.0).abs() < 1e-10);
}
#[test]
fn non_linear_interpolation_between_knots() {
let data = vec![(0.0, 1.0), (1.0, 2.0), (2.0, 1.5)];
let c = calc(data);
let val = c.compute(&ContextValue::Scalar(0.0), &ctx(1.5)).unwrap();
assert!((val.as_scalar().unwrap() - 1.75).abs() < 1e-10);
}
#[test]
fn clamps_to_first_value_before_range() {
let c = calc(linear_data());
let val = c.compute(&ContextValue::Scalar(0.0), &ctx(-1.0)).unwrap();
assert_eq!(val.as_scalar().unwrap(), 0.0);
}
#[test]
fn clamps_to_last_value_after_range() {
let c = calc(linear_data());
let val = c.compute(&ContextValue::Scalar(0.0), &ctx(5.0)).unwrap();
assert_eq!(val.as_scalar().unwrap(), 2.0);
}
#[test]
fn clamps_exactly_at_lower_bound() {
let c = calc(linear_data());
let val = c.compute(&ContextValue::Scalar(0.0), &ctx(0.0)).unwrap();
assert_eq!(val.as_scalar().unwrap(), 0.0);
}
#[test]
fn clamps_exactly_at_upper_bound() {
let c = calc(linear_data());
let val = c.compute(&ContextValue::Scalar(0.0), &ctx(2.0)).unwrap();
assert_eq!(val.as_scalar().unwrap(), 2.0);
}
#[test]
fn is_object_safe() {
let c: Box<dyn ContextCalculator> = Box::new(calc(linear_data()));
assert_eq!(c.provides(), var());
}
#[cfg(feature = "hdf5")]
#[test]
fn from_hdf5_round_trip() {
let path = std::env::temp_dir().join("oxiflow_test_from_hdf5_round_trip.h5");
{
let file = hdf5::File::create(&path).unwrap();
let group = file.create_group("feed_conc").unwrap();
group
.new_dataset::<f64>()
.shape(3)
.create("t")
.unwrap()
.write_raw(&[0.0, 1.0, 2.0])
.unwrap();
group
.new_dataset::<f64>()
.shape(3)
.create("value")
.unwrap()
.write_raw(&[1.0, 2.0, 1.5])
.unwrap();
}
let variable = ContextVariable::External {
name: "feed_conc".into(),
};
let calc = ExternalTabulated::from_hdf5(&path, variable, Interpolation::Linear).unwrap();
let val = calc.compute(&ContextValue::Scalar(0.0), &ctx(0.5)).unwrap();
assert!((val.as_scalar().unwrap() - 1.5).abs() < 1e-10);
std::fs::remove_file(&path).ok();
}
#[cfg(feature = "hdf5")]
#[test]
fn from_hdf5_rejects_non_external_variable() {
let path = std::env::temp_dir().join("oxiflow_test_from_hdf5_wrong_variant.h5");
let result =
ExternalTabulated::from_hdf5(&path, ContextVariable::Time, Interpolation::Linear);
assert!(matches!(result, Err(OxiflowError::ExternalData(_))));
}
#[cfg(feature = "hdf5")]
#[test]
fn from_hdf5_missing_file_is_persistence_error() {
let path = std::path::PathBuf::from("/nonexistent/path/data.h5");
let variable = ContextVariable::External {
name: "feed_conc".into(),
};
let result = ExternalTabulated::from_hdf5(&path, variable, Interpolation::Linear);
assert!(matches!(result, Err(OxiflowError::Persistence(_))));
}
}