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 {
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());
}
}