use crate::cadence::{self, Cadence};
use indexmap::IndexMap;
use ndarray::ArrayD;
use serde_json::Value;
use std::cell::RefCell;
use std::collections::HashMap;
use std::rc::Rc;
pub type ProviderError = crate::error::MessageError;
fn err(msg: impl Into<String>) -> ProviderError {
crate::error::MessageError(msg.into())
}
pub type ForcingBuffer = Rc<RefCell<HashMap<String, ArrayD<f64>>>>;
#[derive(Debug, Clone)]
pub struct NativeField {
pub array: ArrayD<f64>,
pub coords: IndexMap<String, Vec<f64>>,
}
impl NativeField {
pub fn new(array: ArrayD<f64>) -> Self {
Self {
array,
coords: IndexMap::new(),
}
}
}
pub trait CadenceProvider {
fn materialize(&mut self) -> Result<HashMap<String, NativeField>, ProviderError>;
fn refresh(&mut self, t: f64) -> Result<Option<HashMap<String, NativeField>>, ProviderError>;
fn refresh_times(&self) -> Vec<f64>;
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LoaderBinding {
pub loader: String,
pub cadence: Cadence,
pub variables: Vec<String>,
}
pub fn classify_loader_bindings(
model: &Value,
doc: &Value,
) -> Result<Vec<LoaderBinding>, ProviderError> {
let model = cadence::model_with_loaders(model, doc);
let Some(variables) = model.get("variables").and_then(|v| v.as_object()) else {
return Ok(Vec::new());
};
let mut by_loader: IndexMap<String, LoaderBinding> = IndexMap::new();
for (var_name, var) in variables {
let Ok(parsed) = serde_json::from_value::<crate::types::ModelVariable>(var.clone()) else {
continue;
};
let sources = parsed.update_sources();
let Some(source) = sources.first().copied() else {
continue;
};
if sources.iter().any(|s| *s != source) {
return Err(err(format!(
"variable {var_name:?}: update rules name several data sources ({sources:?}); one parameter is fed by one source"
)));
}
let cad = cadence::seed_leaf(&Value::String(var_name.clone()), &model)
.map_err(|e| err(format!("classifying loader-fed variable {var_name:?}: {e}")))?;
if cad == Cadence::Continuous {
return Err(err(format!(
"loader-fed variable {var_name:?} classified CONTINUOUS — a \
data_ingest forcing must be const or discrete"
)));
}
match by_loader.get_mut(source) {
Some(binding) => {
if binding.cadence != cad {
return Err(err(format!(
"loader {source:?} feeds variables of disagreeing cadence \
({} vs {}) — its `temporal` block must decide one cadence \
for all its outputs",
binding.cadence.as_str(),
cad.as_str()
)));
}
binding.variables.push(var_name.clone());
}
None => {
by_loader.insert(
source.to_string(),
LoaderBinding {
loader: source.to_string(),
cadence: cad,
variables: vec![var_name.clone()],
},
);
}
}
}
Ok(by_loader.into_values().collect())
}
struct LoaderProvider {
binding: LoaderBinding,
provider: Box<dyn CadenceProvider>,
}
struct RemapProvider {
target: String,
inner: Box<dyn CadenceProvider>,
}
impl RemapProvider {
fn remap(
&self,
fields: HashMap<String, NativeField>,
) -> Result<HashMap<String, NativeField>, ProviderError> {
if fields.len() != 1 {
return Err(err(format!(
"provider for {:?} fed {} fields; a `providers=` forcing binds exactly one",
self.target,
fields.len()
)));
}
let (_fed_name, field) = fields.into_iter().next().expect("checked len == 1");
let mut out = HashMap::with_capacity(1);
out.insert(self.target.clone(), field);
Ok(out)
}
}
impl CadenceProvider for RemapProvider {
fn materialize(&mut self) -> Result<HashMap<String, NativeField>, ProviderError> {
let fields = self.inner.materialize()?;
self.remap(fields)
}
fn refresh(&mut self, t: f64) -> Result<Option<HashMap<String, NativeField>>, ProviderError> {
match self.inner.refresh(t)? {
Some(fields) => Ok(Some(self.remap(fields)?)),
None => Ok(None),
}
}
fn refresh_times(&self) -> Vec<f64> {
self.inner.refresh_times()
}
}
pub struct RefreshExecutor {
loaders: Vec<LoaderProvider>,
}
impl RefreshExecutor {
pub fn new(
model: &Value,
doc: &Value,
mut providers: HashMap<String, Box<dyn CadenceProvider>>,
) -> Result<Self, ProviderError> {
let bindings = classify_loader_bindings(model, doc)?;
let mut loaders = Vec::with_capacity(bindings.len());
for binding in bindings {
let provider = providers.remove(&binding.loader).ok_or_else(|| {
err(format!(
"no provider supplied for loader {:?} (feeds {:?})",
binding.loader, binding.variables
))
})?;
loaders.push(LoaderProvider { binding, provider });
}
Ok(Self { loaders })
}
pub fn from_providers(providers: HashMap<String, Box<dyn CadenceProvider>>) -> Self {
let mut loaders = Vec::with_capacity(providers.len());
for (var, provider) in providers {
let cadence = if provider.refresh_times().is_empty() {
Cadence::Const
} else {
Cadence::Discrete
};
let remapped: Box<dyn CadenceProvider> = Box::new(RemapProvider {
target: var.clone(),
inner: provider,
});
loaders.push(LoaderProvider {
binding: LoaderBinding {
loader: var.clone(),
cadence,
variables: vec![var],
},
provider: remapped,
});
}
Self { loaders }
}
pub fn bindings(&self) -> impl Iterator<Item = &LoaderBinding> {
self.loaders.iter().map(|lp| &lp.binding)
}
pub fn materialize_const(
&mut self,
forcing: &ForcingBuffer,
) -> Result<Vec<String>, ProviderError> {
let RefreshExecutor { loaders } = self;
let mut written = Vec::new();
for lp in loaders.iter_mut() {
if lp.binding.cadence != Cadence::Const {
continue;
}
let fields = lp.provider.materialize()?;
write_fields(&fields, forcing, &mut written)?;
}
written.sort();
written.dedup();
Ok(written)
}
pub fn refresh_times(&self) -> Vec<f64> {
let mut times: Vec<f64> = self
.loaders
.iter()
.filter(|lp| lp.binding.cadence == Cadence::Discrete)
.flat_map(|lp| lp.provider.refresh_times())
.collect();
times.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
times.dedup();
times
}
pub fn refresh_at(
&mut self,
t: f64,
forcing: &ForcingBuffer,
) -> Result<Vec<String>, ProviderError> {
let RefreshExecutor { loaders } = self;
let mut written = Vec::new();
for lp in loaders.iter_mut() {
if lp.binding.cadence != Cadence::Discrete {
continue;
}
if let Some(fields) = lp.provider.refresh(t)? {
write_fields(&fields, forcing, &mut written)?;
}
}
written.sort();
written.dedup();
Ok(written)
}
}
fn write_fields(
fields: &HashMap<String, NativeField>,
forcing: &ForcingBuffer,
written: &mut Vec<String>,
) -> Result<(), ProviderError> {
for (var, native) in fields {
forcing
.borrow_mut()
.insert(var.clone(), native.array.clone());
written.push(var.clone());
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use ndarray::IxDyn;
use serde_json::json;
fn buffer() -> ForcingBuffer {
Rc::new(RefCell::new(HashMap::new()))
}
fn field(values: &[f64]) -> NativeField {
NativeField::new(ArrayD::from_shape_vec(IxDyn(&[values.len()]), values.to_vec()).unwrap())
}
fn buf_get(forcing: &ForcingBuffer, var: &str) -> Option<Vec<f64>> {
forcing
.borrow()
.get(var)
.map(|a| a.iter().copied().collect())
}
struct FakeProvider {
var: String,
materialize_value: Option<Vec<f64>>,
schedule: Vec<(f64, Option<Vec<f64>>)>,
materialize_calls: Rc<RefCell<usize>>,
refresh_log: Rc<RefCell<Vec<f64>>>,
}
impl FakeProvider {
fn const_loader(var: &str, value: f64) -> Self {
Self {
var: var.to_string(),
materialize_value: Some(vec![value]),
schedule: Vec::new(),
materialize_calls: Rc::new(RefCell::new(0)),
refresh_log: Rc::new(RefCell::new(Vec::new())),
}
}
fn discrete_loader(var: &str, schedule: Vec<(f64, Option<f64>)>) -> Self {
let schedule = schedule
.into_iter()
.map(|(t, v)| (t, v.map(|x| vec![x])))
.collect();
Self::discrete_loader_field(var, schedule)
}
fn discrete_loader_field(var: &str, schedule: Vec<(f64, Option<Vec<f64>>)>) -> Self {
Self {
var: var.to_string(),
materialize_value: None,
schedule,
materialize_calls: Rc::new(RefCell::new(0)),
refresh_log: Rc::new(RefCell::new(Vec::new())),
}
}
}
impl CadenceProvider for FakeProvider {
fn materialize(&mut self) -> Result<HashMap<String, NativeField>, ProviderError> {
*self.materialize_calls.borrow_mut() += 1;
let v = self
.materialize_value
.clone()
.expect("materialize called on a provider with no CONST baseline");
Ok(HashMap::from([(self.var.clone(), field(&v))]))
}
fn refresh(
&mut self,
t: f64,
) -> Result<Option<HashMap<String, NativeField>>, ProviderError> {
self.refresh_log.borrow_mut().push(t);
for (anchor, value) in &self.schedule {
if *anchor == t {
return Ok(value
.as_ref()
.map(|v| HashMap::from([(self.var.clone(), field(v))])));
}
}
Ok(None) }
fn refresh_times(&self) -> Vec<f64> {
self.schedule.iter().map(|(a, _)| *a).collect()
}
}
fn mixed_doc() -> Value {
json!({
"models": {"M": {"variables": {
"u": {"type": "unknown", "shape": ["i"], "default": 0.0},
"wind": {"type": "parameter", "shape": ["i"],
"update": {"kind": "data", "source": "met", "from": {"file_variable": "F"}}},
"elev": {"type": "parameter", "shape": ["i"],
"update": {"kind": "data", "source": "topo", "from": {"file_variable": "F"}}}
}}},
"data_sources": {
"met": {"kind": "grid", "temporal": {"frequency": "PT6H"}},
"topo": {"kind": "static"}
}
})
}
#[test]
fn classify_splits_const_and_discrete_by_temporal() {
let doc = mixed_doc();
let bindings = classify_loader_bindings(&doc["models"]["M"], &doc).unwrap();
let met = bindings.iter().find(|b| b.loader == "met").unwrap();
let topo = bindings.iter().find(|b| b.loader == "topo").unwrap();
assert_eq!(met.cadence, Cadence::Discrete, "temporal loader → DISCRETE");
assert_eq!(met.variables, vec!["wind".to_string()]);
assert_eq!(topo.cadence, Cadence::Const, "no-temporal loader → CONST");
assert_eq!(topo.variables, vec!["elev".to_string()]);
}
#[test]
fn classify_groups_multiple_vars_under_one_loader() {
let doc = json!({
"models": {"M": {"variables": {
"a": {"type": "parameter", "shape": [],
"update": {"kind": "data", "source": "met", "from": {"file_variable": "F"}}},
"b": {"type": "parameter", "shape": [],
"update": {"kind": "data", "source": "met", "from": {"file_variable": "F"}}}
}}},
"data_sources": {"met": {"kind": "grid", "temporal": {"frequency": "PT1H"}}}
});
let bindings = classify_loader_bindings(&doc["models"]["M"], &doc).unwrap();
assert_eq!(bindings.len(), 1);
assert_eq!(bindings[0].cadence, Cadence::Discrete);
assert_eq!(
bindings[0].variables,
vec!["a".to_string(), "b".to_string()]
);
}
#[test]
fn classify_ignores_non_data_updates_and_plain_vars() {
let doc = json!({
"models": {"M": {"variables": {
"u": {"type": "unknown"},
"p": {"type": "parameter"},
"sched": {"type": "parameter", "shape": [],
"update": {"kind": "schedule", "times": [1.0, 2.0],
"handler": {"handler_id": "reload_sched"}}}
}}},
"data_sources": {}
});
let bindings = classify_loader_bindings(&doc["models"]["M"], &doc).unwrap();
assert!(bindings.is_empty(), "no data_ingest loader-fed variable");
}
#[test]
fn const_loader_materialized_once_discrete_untouched() {
let doc = mixed_doc();
let topo = FakeProvider::const_loader("elev", 7.0);
let met = FakeProvider::discrete_loader("wind", vec![(0.0, Some(1.0))]);
let topo_calls = Rc::clone(&topo.materialize_calls);
let met_calls = Rc::clone(&met.materialize_calls);
let providers: HashMap<String, Box<dyn CadenceProvider>> = HashMap::from([
(
"topo".to_string(),
Box::new(topo) as Box<dyn CadenceProvider>,
),
("met".to_string(), Box::new(met) as Box<dyn CadenceProvider>),
]);
let mut exec = RefreshExecutor::new(&doc["models"]["M"], &doc, providers).unwrap();
let forcing = buffer();
let written = exec.materialize_const(&forcing).unwrap();
assert_eq!(written, vec!["elev".to_string()]);
assert_eq!(buf_get(&forcing, "elev"), Some(vec![7.0]));
assert_eq!(
buf_get(&forcing, "wind"),
None,
"DISCRETE not loaded at setup"
);
assert_eq!(*topo_calls.borrow(), 1, "CONST materialized exactly once");
assert_eq!(*met_calls.borrow(), 0, "DISCRETE never materialized");
}
#[test]
fn refresh_times_is_sorted_union_over_discrete_only() {
let doc = json!({
"models": {"M": {"variables": {
"wind": {"type": "parameter", "shape": [],
"update": {"kind": "data", "source": "met", "from": {"file_variable": "F"}}},
"bc": {"type": "parameter", "shape": [],
"update": {"kind": "data", "source": "bcs", "from": {"file_variable": "F"}}},
"elev": {"type": "parameter", "shape": [],
"update": {"kind": "data", "source": "topo", "from": {"file_variable": "F"}}}
}}},
"data_sources": {
"met": {"kind": "grid", "temporal": {"frequency": "PT6H"}},
"bcs": {"kind": "grid", "temporal": {"frequency": "PT3H"}},
"topo": {"kind": "static"}
}
});
let providers: HashMap<String, Box<dyn CadenceProvider>> = HashMap::from([
(
"met".to_string(),
Box::new(FakeProvider::discrete_loader(
"wind",
vec![(0.0, Some(1.0)), (6.0, Some(2.0)), (12.0, Some(3.0))],
)) as Box<dyn CadenceProvider>,
),
(
"bcs".to_string(),
Box::new(FakeProvider::discrete_loader(
"bc",
vec![
(0.0, Some(1.0)),
(3.0, Some(2.0)),
(6.0, Some(3.0)),
(9.0, Some(4.0)),
(12.0, Some(5.0)),
],
)) as Box<dyn CadenceProvider>,
),
(
"topo".to_string(),
Box::new(FakeProvider::const_loader("elev", 7.0)) as Box<dyn CadenceProvider>,
),
]);
let exec = RefreshExecutor::new(&doc["models"]["M"], &doc, providers).unwrap();
assert_eq!(exec.refresh_times(), vec![0.0, 3.0, 6.0, 9.0, 12.0]);
}
#[test]
fn discrete_refresh_writes_at_anchors_and_honors_none_skip() {
let doc = mixed_doc();
let met = FakeProvider::discrete_loader(
"wind",
vec![(0.0, Some(10.0)), (6.0, None), (12.0, Some(30.0))],
);
let refresh_log = Rc::clone(&met.refresh_log);
let providers: HashMap<String, Box<dyn CadenceProvider>> = HashMap::from([
("met".to_string(), Box::new(met) as Box<dyn CadenceProvider>),
(
"topo".to_string(),
Box::new(FakeProvider::const_loader("elev", 7.0)) as Box<dyn CadenceProvider>,
),
]);
let mut exec = RefreshExecutor::new(&doc["models"]["M"], &doc, providers).unwrap();
let forcing = buffer();
assert_eq!(
exec.refresh_at(0.0, &forcing).unwrap(),
vec!["wind".to_string()]
);
assert_eq!(buf_get(&forcing, "wind"), Some(vec![10.0]));
assert_eq!(
exec.refresh_at(6.0, &forcing).unwrap(),
Vec::<String>::new(),
"None-skip: nothing written at an unchanged anchor"
);
assert_eq!(
buf_get(&forcing, "wind"),
Some(vec![10.0]),
"the prior field is retained across a None refresh"
);
assert_eq!(
exec.refresh_at(12.0, &forcing).unwrap(),
vec!["wind".to_string()]
);
assert_eq!(buf_get(&forcing, "wind"), Some(vec![30.0]));
assert_eq!(
*refresh_log.borrow(),
vec![0.0, 6.0, 12.0],
"refresh fired at every anchor"
);
}
#[test]
fn refresh_writes_native_field_into_buffer() {
let doc = mixed_doc();
let met = FakeProvider::discrete_loader("wind", vec![(0.0, Some(5.0))]);
let providers: HashMap<String, Box<dyn CadenceProvider>> = HashMap::from([
("met".to_string(), Box::new(met) as Box<dyn CadenceProvider>),
(
"topo".to_string(),
Box::new(FakeProvider::const_loader("elev", 4.0)) as Box<dyn CadenceProvider>,
),
]);
let mut exec = RefreshExecutor::new(&doc["models"]["M"], &doc, providers).unwrap();
let forcing = buffer();
exec.materialize_const(&forcing).unwrap();
assert_eq!(
buf_get(&forcing, "elev"),
Some(vec![4.0]),
"CONST native field written unchanged"
);
exec.refresh_at(0.0, &forcing).unwrap();
assert_eq!(
buf_get(&forcing, "wind"),
Some(vec![5.0]),
"DISCRETE native field written unchanged"
);
}
#[test]
fn new_errors_when_a_classified_loader_has_no_provider() {
let doc = mixed_doc();
let providers: HashMap<String, Box<dyn CadenceProvider>> = HashMap::from([(
"met".to_string(),
Box::new(FakeProvider::discrete_loader(
"wind",
vec![(0.0, Some(1.0))],
)) as Box<dyn CadenceProvider>,
)]);
let e = RefreshExecutor::new(&doc["models"]["M"], &doc, providers)
.map(|_| ())
.unwrap_err();
assert!(
e.0.contains("topo"),
"error names the unprovided loader: {}",
e.0
);
}
#[test]
fn end_to_end_executor_drives_the_pr1_forcing_buffer_into_the_rhs() {
use crate::parse::load_string;
use crate::simulate_array::ArrayCompiled;
let model_json = r#"
{
"esm": "1.1.0",
"metadata": {
"name": "r1_forcing"
},
"models": {
"Forced": {
"variables": {
"u": {
"type": "unknown",
"shape": [
"i"
],
"default": 0.0
}
},
"equations": [
{
"lhs": {
"op": "faq",
"args": [],
"output_idx": [
"i"
],
"expr": {
"op": "D",
"args": [
{
"op": "index",
"args": [
"u",
"i"
]
}
],
"wrt": "t"
},
"ranges": {
"i": [
1,
3
]
}
},
"rhs": {
"op": "faq",
"args": [],
"output_idx": [
"i"
],
"ranges": {
"i": [
1,
3
]
},
"expr": {
"op": "index",
"args": [
"wind",
"i"
]
}
}
}
]
}
}
}
"#;
let file = load_string(model_json).expect("parse forced model");
let compiled = ArrayCompiled::from_file(&file).expect("compile forced model");
let class_doc = json!({
"models": {"Forced": {"variables": {
"wind": {"type": "parameter", "shape": ["i"],
"update": {"kind": "data", "source": "met", "from": {"file_variable": "F"}}}
}}},
"data_sources": {"met": {"kind": "grid", "temporal": {"frequency": "PT6H"}}}
});
let met = FakeProvider::discrete_loader_field(
"wind",
vec![
(0.0, Some(vec![2.0, 3.0, 4.0])),
(6.0, Some(vec![9.0, 8.0, 7.0])),
],
);
let providers: HashMap<String, Box<dyn CadenceProvider>> =
HashMap::from([("met".to_string(), Box::new(met) as Box<dyn CadenceProvider>)]);
let mut exec =
RefreshExecutor::new(&class_doc["models"]["Forced"], &class_doc, providers).unwrap();
let forcing = compiled.forcing_handle();
let params = HashMap::new();
let state = vec![0.0, 0.0, 0.0];
assert_eq!(exec.refresh_times(), vec![0.0, 6.0]);
exec.refresh_at(0.0, &forcing).unwrap();
let (dy0, _) = compiled.debug_eval_rhs(&state, 0.0, ¶ms, false);
assert_eq!(
dy0,
vec![2.0, 3.0, 4.0],
"RHS reads the t=0 forcing the executor wrote"
);
exec.refresh_at(6.0, &forcing).unwrap();
let (dy6, _) = compiled.debug_eval_rhs(&state, 6.0, ¶ms, false);
assert_eq!(
dy6,
vec![9.0, 8.0, 7.0],
"RHS reflects the refreshed forcing"
);
}
}