use crate::component::GridType;
use crate::errors::{RSCMError, RSCMResult};
use pyo3::{pyclass, pymethods};
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum AggregateOp {
Sum,
Mean,
Weighted(Vec<f64>),
}
impl AggregateOp {
pub fn name(&self) -> &'static str {
match self {
AggregateOp::Sum => "Sum",
AggregateOp::Mean => "Mean",
AggregateOp::Weighted(_) => "Weighted",
}
}
pub fn weights(&self) -> Option<&[f64]> {
match self {
AggregateOp::Weighted(w) => Some(w),
_ => None,
}
}
}
#[pyclass]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SchemaVariableDefinition {
#[pyo3(get)]
pub name: String,
#[pyo3(get)]
pub unit: String,
#[pyo3(get)]
pub grid_type: GridType,
}
impl SchemaVariableDefinition {
pub fn new(name: impl Into<String>, unit: impl Into<String>) -> Self {
Self {
name: name.into(),
unit: unit.into(),
grid_type: GridType::Scalar,
}
}
pub fn with_grid(
name: impl Into<String>,
unit: impl Into<String>,
grid_type: GridType,
) -> Self {
Self {
name: name.into(),
unit: unit.into(),
grid_type,
}
}
}
#[pymethods]
impl SchemaVariableDefinition {
#[new]
#[pyo3(signature = (name, unit, grid_type=None))]
fn py_new(name: String, unit: String, grid_type: Option<GridType>) -> Self {
Self {
name,
unit,
grid_type: grid_type.unwrap_or(GridType::Scalar),
}
}
fn __repr__(&self) -> String {
format!(
"SchemaVariableDefinition(name={:?}, unit={:?}, grid_type={:?})",
self.name, self.unit, self.grid_type
)
}
}
#[pyclass]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AggregateDefinition {
#[pyo3(get)]
pub name: String,
#[pyo3(get)]
pub unit: String,
#[pyo3(get)]
pub grid_type: GridType,
pub operation: AggregateOp,
#[pyo3(get)]
pub contributors: Vec<String>,
}
impl AggregateDefinition {
pub fn new(name: impl Into<String>, unit: impl Into<String>, operation: AggregateOp) -> Self {
Self {
name: name.into(),
unit: unit.into(),
grid_type: GridType::Scalar,
operation,
contributors: Vec::new(),
}
}
pub fn with_grid(
name: impl Into<String>,
unit: impl Into<String>,
grid_type: GridType,
operation: AggregateOp,
) -> Self {
Self {
name: name.into(),
unit: unit.into(),
grid_type,
operation,
contributors: Vec::new(),
}
}
}
#[pymethods]
impl AggregateDefinition {
#[getter]
fn operation_type(&self) -> &'static str {
self.operation.name()
}
#[getter]
fn weights(&self) -> Option<Vec<f64>> {
self.operation.weights().map(|w| w.to_vec())
}
fn __repr__(&self) -> String {
format!(
"AggregateDefinition(name={:?}, unit={:?}, grid_type={:?}, operation={:?}, contributors={:?})",
self.name, self.unit, self.grid_type, self.operation, self.contributors
)
}
}
#[pyclass]
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
pub struct VariableSchema {
#[pyo3(get)]
pub variables: HashMap<String, SchemaVariableDefinition>,
#[pyo3(get)]
pub aggregates: HashMap<String, AggregateDefinition>,
}
#[derive(Deserialize)]
struct VariableSchemaRaw {
variables: HashMap<String, SchemaVariableDefinition>,
aggregates: HashMap<String, AggregateDefinition>,
}
impl<'de> Deserialize<'de> for VariableSchema {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let raw = VariableSchemaRaw::deserialize(deserializer)?;
let schema = VariableSchema {
variables: raw.variables,
aggregates: raw.aggregates,
};
schema.validate().map_err(serde::de::Error::custom)?;
Ok(schema)
}
}
impl VariableSchema {
pub fn new() -> Self {
Self::default()
}
pub fn variable(mut self, name: impl Into<String>, unit: impl Into<String>) -> Self {
let name = name.into();
let def = SchemaVariableDefinition::new(name.clone(), unit);
self.variables.insert(name, def);
self
}
pub fn variable_with_grid(
mut self,
name: impl Into<String>,
unit: impl Into<String>,
grid_type: GridType,
) -> Self {
let name = name.into();
let def = SchemaVariableDefinition::with_grid(name.clone(), unit, grid_type);
self.variables.insert(name, def);
self
}
pub fn aggregate(
self,
name: impl Into<String>,
unit: impl Into<String>,
operation: AggregateOp,
) -> AggregateBuilder {
let def = AggregateDefinition::new(name, unit, operation);
AggregateBuilder {
schema: self,
aggregate: def,
}
}
pub fn aggregate_with_grid(
self,
name: impl Into<String>,
unit: impl Into<String>,
grid_type: GridType,
operation: AggregateOp,
) -> AggregateBuilder {
let def = AggregateDefinition::with_grid(name, unit, grid_type, operation);
AggregateBuilder {
schema: self,
aggregate: def,
}
}
pub fn contains(&self, name: &str) -> bool {
self.variables.contains_key(name) || self.aggregates.contains_key(name)
}
pub fn get_variable(&self, name: &str) -> Option<&SchemaVariableDefinition> {
self.variables.get(name)
}
pub fn get_aggregate(&self, name: &str) -> Option<&AggregateDefinition> {
self.aggregates.get(name)
}
pub fn get_unit(&self, name: &str) -> Option<&str> {
self.variables
.get(name)
.map(|v| v.unit.as_str())
.or_else(|| self.aggregates.get(name).map(|a| a.unit.as_str()))
}
pub fn get_grid_type(&self, name: &str) -> Option<GridType> {
self.variables
.get(name)
.map(|v| v.grid_type)
.or_else(|| self.aggregates.get(name).map(|a| a.grid_type))
}
pub fn validate(&self) -> RSCMResult<()> {
for (agg_name, agg_def) in &self.aggregates {
for contributor in &agg_def.contributors {
if !self.contains(contributor) {
return Err(RSCMError::UndefinedContributor {
contributor: contributor.clone(),
aggregate: agg_name.clone(),
});
}
if let Some(contributor_unit) = self.get_unit(contributor) {
if contributor_unit != agg_def.unit {
return Err(RSCMError::SchemaUnitMismatch {
aggregate: agg_name.clone(),
contributor: contributor.clone(),
contributor_unit: contributor_unit.to_string(),
aggregate_unit: agg_def.unit.clone(),
});
}
}
if let Some(contributor_grid) = self.get_grid_type(contributor) {
if contributor_grid != agg_def.grid_type {
return Err(RSCMError::SchemaGridTypeMismatch {
aggregate: agg_name.clone(),
contributor: contributor.clone(),
contributor_grid: format!("{:?}", contributor_grid),
aggregate_grid: format!("{:?}", agg_def.grid_type),
});
}
}
}
if let AggregateOp::Weighted(weights) = &agg_def.operation {
if weights.len() != agg_def.contributors.len() {
return Err(RSCMError::WeightCountMismatch {
aggregate: agg_name.clone(),
weight_count: weights.len(),
contributor_count: agg_def.contributors.len(),
});
}
}
}
self.check_circular_dependencies()?;
Ok(())
}
pub fn topological_order_aggregates(&self) -> Vec<String> {
let mut in_degree: HashMap<String, usize> = HashMap::new();
let mut dependents: HashMap<String, Vec<String>> = HashMap::new();
for name in self.aggregates.keys() {
in_degree.entry(name.clone()).or_insert(0);
dependents.entry(name.clone()).or_default();
}
for (name, agg_def) in &self.aggregates {
for contributor in &agg_def.contributors {
if self.aggregates.contains_key(contributor) {
*in_degree.get_mut(name).unwrap() += 1;
dependents.get_mut(contributor).unwrap().push(name.clone());
}
}
}
let mut result = Vec::new();
let mut queue: Vec<String> = in_degree
.iter()
.filter(|(_, °)| deg == 0)
.map(|(name, _)| name.clone())
.collect();
queue.sort();
while let Some(name) = queue.pop() {
result.push(name.clone());
if let Some(deps) = dependents.get(&name) {
for dep in deps {
if let Some(deg) = in_degree.get_mut(dep) {
*deg -= 1;
if *deg == 0 {
let pos = queue.binary_search(dep).unwrap_or_else(|e| e);
queue.insert(pos, dep.clone());
}
}
}
}
}
result
}
fn check_circular_dependencies(&self) -> RSCMResult<()> {
let mut visited = HashSet::new();
let mut path = Vec::new();
let mut path_set = HashSet::new();
for agg_name in self.aggregates.keys() {
if !visited.contains(agg_name) {
self.dfs_cycle_check(agg_name, &mut visited, &mut path, &mut path_set)?;
}
}
Ok(())
}
fn dfs_cycle_check(
&self,
current: &str,
visited: &mut HashSet<String>,
path: &mut Vec<String>,
path_set: &mut HashSet<String>,
) -> RSCMResult<()> {
if path_set.contains(current) {
let cycle_start = path.iter().position(|n| n == current).unwrap();
let mut cycle_nodes: Vec<_> = path[cycle_start..].to_vec();
cycle_nodes.push(current.to_string());
let cycle = cycle_nodes.join(" -> ");
return Err(RSCMError::AggregateCircularDependency { cycle });
}
if visited.contains(current) {
return Ok(());
}
path.push(current.to_string());
path_set.insert(current.to_string());
if let Some(agg_def) = self.aggregates.get(current) {
for contributor in &agg_def.contributors {
if self.aggregates.contains_key(contributor) {
self.dfs_cycle_check(contributor, visited, path, path_set)?;
}
}
}
path.pop();
path_set.remove(current);
visited.insert(current.to_string());
Ok(())
}
}
#[pymethods]
impl VariableSchema {
#[new]
fn py_new() -> Self {
Self::new()
}
#[pyo3(name = "add_variable")]
#[pyo3(signature = (name, unit, grid_type=None))]
fn py_add_variable(&mut self, name: String, unit: String, grid_type: Option<GridType>) {
let def = SchemaVariableDefinition {
name: name.clone(),
unit,
grid_type: grid_type.unwrap_or(GridType::Scalar),
};
self.variables.insert(name, def);
}
#[pyo3(name = "add_aggregate")]
#[pyo3(signature = (name, unit, operation, contributors, weights=None, grid_type=None))]
fn py_add_aggregate(
&mut self,
name: String,
unit: String,
operation: &str,
contributors: Vec<String>,
weights: Option<Vec<f64>>,
grid_type: Option<GridType>,
) -> pyo3::PyResult<()> {
let op = match operation {
"Sum" => AggregateOp::Sum,
"Mean" => AggregateOp::Mean,
"Weighted" => {
let w = weights.ok_or_else(|| {
pyo3::exceptions::PyValueError::new_err(
"weights must be provided for Weighted operation",
)
})?;
AggregateOp::Weighted(w)
}
_ => {
return Err(pyo3::exceptions::PyValueError::new_err(format!(
"Unknown operation '{}'. Valid operations: Sum, Mean, Weighted",
operation
)))
}
};
let def = AggregateDefinition {
name: name.clone(),
unit,
grid_type: grid_type.unwrap_or(GridType::Scalar),
operation: op,
contributors,
};
self.aggregates.insert(name, def);
Ok(())
}
#[pyo3(name = "contains")]
fn py_contains(&self, name: &str) -> bool {
self.contains(name)
}
#[pyo3(name = "validate")]
fn py_validate(&self) -> pyo3::PyResult<()> {
self.validate()
.map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))
}
fn __repr__(&self) -> String {
format!(
"VariableSchema(variables={}, aggregates={})",
self.variables.len(),
self.aggregates.len()
)
}
}
pub struct AggregateBuilder {
schema: VariableSchema,
aggregate: AggregateDefinition,
}
impl AggregateBuilder {
pub fn from(mut self, contributor: impl Into<String>) -> Self {
self.aggregate.contributors.push(contributor.into());
self
}
pub fn build(mut self) -> VariableSchema {
let name = self.aggregate.name.clone();
self.schema.aggregates.insert(name, self.aggregate);
self.schema
}
}
pub fn compute_aggregate(contributors: &[f64], op: &AggregateOp) -> f64 {
match op {
AggregateOp::Sum => {
let valid: Vec<f64> = contributors
.iter()
.filter(|v| !v.is_nan())
.copied()
.collect();
if valid.is_empty() {
f64::NAN
} else {
valid.iter().sum()
}
}
AggregateOp::Mean => {
let valid: Vec<f64> = contributors
.iter()
.filter(|v| !v.is_nan())
.copied()
.collect();
if valid.is_empty() {
f64::NAN
} else {
valid.iter().sum::<f64>() / valid.len() as f64
}
}
AggregateOp::Weighted(weights) => {
let valid_pairs: Vec<(f64, f64)> = contributors
.iter()
.zip(weights.iter())
.filter(|(v, _)| !v.is_nan())
.map(|(v, w)| (*v, *w))
.collect();
if valid_pairs.is_empty() {
f64::NAN
} else {
valid_pairs.iter().map(|(v, w)| v * w).sum()
}
}
}
}
use crate::component::{
Component, InputState, OutputState, RequirementDefinition, RequirementType,
};
use crate::state::StateValue;
use crate::timeseries::Time;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AggregatorComponent {
pub aggregate_name: String,
pub unit: String,
pub grid_type: GridType,
pub operation: AggregateOp,
pub contributors: Vec<String>,
}
impl AggregatorComponent {
pub fn from_definition(def: &AggregateDefinition) -> Self {
Self {
aggregate_name: def.name.clone(),
unit: def.unit.clone(),
grid_type: def.grid_type,
operation: def.operation.clone(),
contributors: def.contributors.clone(),
}
}
}
#[typetag::serde]
impl Component for AggregatorComponent {
fn definitions(&self) -> Vec<RequirementDefinition> {
let mut defs = Vec::with_capacity(self.contributors.len() + 1);
for contributor in &self.contributors {
defs.push(RequirementDefinition::with_grid(
contributor,
&self.unit,
RequirementType::Input,
self.grid_type,
));
}
defs.push(RequirementDefinition::with_grid(
&self.aggregate_name,
&self.unit,
RequirementType::Output,
self.grid_type,
));
defs
}
fn solve(
&self,
_t_current: Time,
_t_next: Time,
input_state: &InputState,
) -> RSCMResult<OutputState> {
let mut output = OutputState::new();
match self.grid_type {
GridType::Scalar => {
let values: Vec<f64> = self
.contributors
.iter()
.map(|name| {
let window = input_state.get_scalar_window(name);
window.at_end().unwrap_or_else(|| window.at_start())
})
.collect();
let result = compute_aggregate(&values, &self.operation);
output.insert(self.aggregate_name.clone(), StateValue::Scalar(result));
}
GridType::FourBox => {
use crate::state::FourBoxSlice;
let mut region_values: [Vec<f64>; 4] = Default::default();
for name in &self.contributors {
let window = input_state.get_four_box_window(name);
let values = window.at_end_all().unwrap_or_else(|| window.at_start_all());
for (i, val) in values.into_iter().enumerate() {
region_values[i].push(val);
}
}
let mut result = FourBoxSlice::new();
for (i, vals) in region_values.iter().enumerate() {
let agg = compute_aggregate(vals, &self.operation);
result.0[i] = agg;
}
output.insert(self.aggregate_name.clone(), StateValue::FourBox(result));
}
GridType::Hemispheric => {
use crate::state::HemisphericSlice;
let mut region_values: [Vec<f64>; 2] = Default::default();
for name in &self.contributors {
let window = input_state.get_hemispheric_window(name);
let values = window.at_end_all().unwrap_or_else(|| window.at_start_all());
for (i, val) in values.into_iter().enumerate() {
region_values[i].push(val);
}
}
let mut result = HemisphericSlice::new();
for (i, vals) in region_values.iter().enumerate() {
let agg = compute_aggregate(vals, &self.operation);
result.0[i] = agg;
}
output.insert(self.aggregate_name.clone(), StateValue::Hemispheric(result));
}
}
Ok(output)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_aggregate_op_variants() {
let sum = AggregateOp::Sum;
let mean = AggregateOp::Mean;
let weighted = AggregateOp::Weighted(vec![0.5, 0.3, 0.2]);
assert_eq!(sum, AggregateOp::Sum);
assert_eq!(mean, AggregateOp::Mean);
assert!(matches!(weighted, AggregateOp::Weighted(_)));
}
#[test]
fn test_aggregate_op_serialization() {
let sum = AggregateOp::Sum;
let json = serde_json::to_string(&sum).unwrap();
let deserialized: AggregateOp = serde_json::from_str(&json).unwrap();
assert_eq!(sum, deserialized);
let weighted = AggregateOp::Weighted(vec![0.5, 0.5]);
let json = serde_json::to_string(&weighted).unwrap();
let deserialized: AggregateOp = serde_json::from_str(&json).unwrap();
assert_eq!(weighted, deserialized);
}
#[test]
fn test_variable_definition_new() {
let def = SchemaVariableDefinition::new("Emissions|CO2", "GtCO2/yr");
assert_eq!(def.name, "Emissions|CO2");
assert_eq!(def.unit, "GtCO2/yr");
assert_eq!(def.grid_type, GridType::Scalar);
}
#[test]
fn test_variable_definition_with_grid() {
let def = SchemaVariableDefinition::with_grid("Temperature", "K", GridType::FourBox);
assert_eq!(def.name, "Temperature");
assert_eq!(def.unit, "K");
assert_eq!(def.grid_type, GridType::FourBox);
}
#[test]
fn test_aggregate_definition_new() {
let def = AggregateDefinition::new("Total ERF", "W/m^2", AggregateOp::Sum);
assert_eq!(def.name, "Total ERF");
assert_eq!(def.unit, "W/m^2");
assert_eq!(def.grid_type, GridType::Scalar);
assert_eq!(def.operation, AggregateOp::Sum);
assert!(def.contributors.is_empty());
}
#[test]
fn test_variable_schema_builder() {
let schema = VariableSchema::new()
.variable("Emissions|CO2", "GtCO2/yr")
.variable("Concentration|CO2", "ppm")
.variable_with_grid("Regional Temperature", "K", GridType::FourBox);
assert_eq!(schema.variables.len(), 3);
assert!(schema.contains("Emissions|CO2"));
assert!(schema.contains("Concentration|CO2"));
assert!(schema.contains("Regional Temperature"));
assert!(!schema.contains("Nonexistent"));
let co2 = schema.get_variable("Emissions|CO2").unwrap();
assert_eq!(co2.unit, "GtCO2/yr");
assert_eq!(co2.grid_type, GridType::Scalar);
let temp = schema.get_variable("Regional Temperature").unwrap();
assert_eq!(temp.grid_type, GridType::FourBox);
}
#[test]
fn test_variable_schema_with_aggregate() {
let schema = VariableSchema::new()
.variable("ERF|CO2", "W/m^2")
.variable("ERF|CH4", "W/m^2")
.aggregate("Total ERF", "W/m^2", AggregateOp::Sum)
.from("ERF|CO2")
.from("ERF|CH4")
.build();
assert_eq!(schema.variables.len(), 2);
assert_eq!(schema.aggregates.len(), 1);
assert!(schema.contains("ERF|CO2"));
assert!(schema.contains("Total ERF"));
let agg = schema.get_aggregate("Total ERF").unwrap();
assert_eq!(agg.unit, "W/m^2");
assert_eq!(agg.operation, AggregateOp::Sum);
assert_eq!(agg.contributors, vec!["ERF|CO2", "ERF|CH4"]);
}
#[test]
fn test_variable_schema_weighted_aggregate() {
let schema = VariableSchema::new()
.variable("Source A", "units")
.variable("Source B", "units")
.aggregate(
"Weighted Total",
"units",
AggregateOp::Weighted(vec![0.7, 0.3]),
)
.from("Source A")
.from("Source B")
.build();
let agg = schema.get_aggregate("Weighted Total").unwrap();
assert!(matches!(agg.operation, AggregateOp::Weighted(ref w) if w == &vec![0.7, 0.3]));
}
#[test]
fn test_variable_schema_chained_aggregates() {
let schema = VariableSchema::new()
.variable("ERF|CO2", "W/m^2")
.variable("ERF|CH4", "W/m^2")
.variable("ERF|Other", "W/m^2")
.aggregate("ERF|GHG", "W/m^2", AggregateOp::Sum)
.from("ERF|CO2")
.from("ERF|CH4")
.build()
.aggregate("Total ERF", "W/m^2", AggregateOp::Sum)
.from("ERF|GHG")
.from("ERF|Other")
.build();
assert_eq!(schema.aggregates.len(), 2);
let ghg = schema.get_aggregate("ERF|GHG").unwrap();
assert_eq!(ghg.contributors, vec!["ERF|CO2", "ERF|CH4"]);
let total = schema.get_aggregate("Total ERF").unwrap();
assert_eq!(total.contributors, vec!["ERF|GHG", "ERF|Other"]);
}
#[test]
fn test_variable_schema_get_unit() {
let schema = VariableSchema::new()
.variable("Emissions|CO2", "GtCO2/yr")
.aggregate("Total", "GtCO2/yr", AggregateOp::Sum)
.from("Emissions|CO2")
.build();
assert_eq!(schema.get_unit("Emissions|CO2"), Some("GtCO2/yr"));
assert_eq!(schema.get_unit("Total"), Some("GtCO2/yr"));
assert_eq!(schema.get_unit("Nonexistent"), None);
}
#[test]
fn test_variable_schema_get_grid_type() {
let schema = VariableSchema::new()
.variable("Global", "K")
.variable_with_grid("Regional", "K", GridType::FourBox)
.aggregate_with_grid("Regional Total", "K", GridType::FourBox, AggregateOp::Sum)
.from("Regional")
.build();
assert_eq!(schema.get_grid_type("Global"), Some(GridType::Scalar));
assert_eq!(schema.get_grid_type("Regional"), Some(GridType::FourBox));
assert_eq!(
schema.get_grid_type("Regional Total"),
Some(GridType::FourBox)
);
assert_eq!(schema.get_grid_type("Nonexistent"), None);
}
#[test]
fn test_variable_schema_serialization_roundtrip() {
let schema = VariableSchema::new()
.variable("ERF|CO2", "W/m^2")
.variable("ERF|CH4", "W/m^2")
.aggregate("Total ERF", "W/m^2", AggregateOp::Sum)
.from("ERF|CO2")
.from("ERF|CH4")
.build();
let json = serde_json::to_string(&schema).unwrap();
let deserialized: VariableSchema = serde_json::from_str(&json).unwrap();
assert_eq!(schema.variables.len(), deserialized.variables.len());
assert_eq!(schema.aggregates.len(), deserialized.aggregates.len());
assert_eq!(
schema.get_aggregate("Total ERF"),
deserialized.get_aggregate("Total ERF")
);
}
#[test]
fn test_variable_schema_toml_serialization() {
let schema = VariableSchema::new()
.variable("ERF|CO2", "W/m^2")
.aggregate("Total ERF", "W/m^2", AggregateOp::Sum)
.from("ERF|CO2")
.build();
let toml = toml::to_string(&schema).unwrap();
let deserialized: VariableSchema = toml::from_str(&toml).unwrap();
assert_eq!(schema, deserialized);
}
#[test]
fn test_empty_schema() {
let schema = VariableSchema::new();
assert!(schema.variables.is_empty());
assert!(schema.aggregates.is_empty());
assert!(!schema.contains("anything"));
}
#[test]
fn test_validate_valid_schema() {
let schema = VariableSchema::new()
.variable("ERF|CO2", "W/m^2")
.variable("ERF|CH4", "W/m^2")
.aggregate("Total ERF", "W/m^2", AggregateOp::Sum)
.from("ERF|CO2")
.from("ERF|CH4")
.build();
assert!(schema.validate().is_ok());
}
#[test]
fn test_validate_empty_schema() {
let schema = VariableSchema::new();
assert!(schema.validate().is_ok());
}
#[test]
fn test_validate_chained_aggregates() {
let schema = VariableSchema::new()
.variable("ERF|CO2", "W/m^2")
.variable("ERF|CH4", "W/m^2")
.variable("ERF|Other", "W/m^2")
.aggregate("ERF|GHG", "W/m^2", AggregateOp::Sum)
.from("ERF|CO2")
.from("ERF|CH4")
.build()
.aggregate("Total ERF", "W/m^2", AggregateOp::Sum)
.from("ERF|GHG")
.from("ERF|Other")
.build();
assert!(schema.validate().is_ok());
}
#[test]
fn test_validate_weighted_aggregate_matching_weights() {
let schema = VariableSchema::new()
.variable("A", "units")
.variable("B", "units")
.aggregate("Total", "units", AggregateOp::Weighted(vec![0.6, 0.4]))
.from("A")
.from("B")
.build();
assert!(schema.validate().is_ok());
}
#[test]
fn test_validate_undefined_contributor() {
let schema = VariableSchema::new()
.variable("ERF|CO2", "W/m^2")
.aggregate("Total ERF", "W/m^2", AggregateOp::Sum)
.from("ERF|CO2")
.from("ERF|CH4") .build();
let err = schema.validate().unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("Undefined contributor"),
"Expected UndefinedContributor error, got: {}",
msg
);
assert!(
msg.contains("ERF|CH4"),
"Error should mention the missing contributor: {}",
msg
);
}
#[test]
fn test_validate_unit_mismatch() {
let schema = VariableSchema::new()
.variable("ERF|CO2", "W/m^2")
.variable("Emissions|CO2", "GtCO2/yr") .aggregate("Total", "W/m^2", AggregateOp::Sum)
.from("ERF|CO2")
.from("Emissions|CO2") .build();
let err = schema.validate().unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("Unit mismatch"),
"Expected SchemaUnitMismatch error, got: {}",
msg
);
assert!(
msg.contains("GtCO2/yr"),
"Error should mention mismatched unit: {}",
msg
);
}
#[test]
fn test_validate_grid_type_mismatch() {
let schema = VariableSchema::new()
.variable("Global Temp", "K")
.variable_with_grid("Regional Temp", "K", GridType::FourBox)
.aggregate("Total Temp", "K", AggregateOp::Sum) .from("Global Temp")
.from("Regional Temp") .build();
let err = schema.validate().unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("Grid type mismatch"),
"Expected SchemaGridTypeMismatch error, got: {}",
msg
);
assert!(
msg.contains("FourBox"),
"Error should mention FourBox: {}",
msg
);
}
#[test]
fn test_validate_weight_count_mismatch() {
let schema = VariableSchema::new()
.variable("A", "units")
.variable("B", "units")
.variable("C", "units")
.aggregate(
"Total",
"units",
AggregateOp::Weighted(vec![0.5, 0.5]), )
.from("A")
.from("B")
.from("C") .build();
let err = schema.validate().unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("Weight count mismatch"),
"Expected WeightCountMismatch error, got: {}",
msg
);
assert!(
msg.contains("2 weights"),
"Error should mention weight count: {}",
msg
);
assert!(
msg.contains("3 contributors"),
"Error should mention contributor count: {}",
msg
);
}
#[test]
fn test_validate_circular_dependency_direct() {
let mut schema = VariableSchema::new();
schema.aggregates.insert(
"A".to_string(),
AggregateDefinition {
name: "A".to_string(),
unit: "units".to_string(),
grid_type: GridType::Scalar,
operation: AggregateOp::Sum,
contributors: vec!["B".to_string()],
},
);
schema.aggregates.insert(
"B".to_string(),
AggregateDefinition {
name: "B".to_string(),
unit: "units".to_string(),
grid_type: GridType::Scalar,
operation: AggregateOp::Sum,
contributors: vec!["A".to_string()],
},
);
let err = schema.validate().unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("Circular dependency"),
"Expected AggregateCircularDependency error, got: {}",
msg
);
}
#[test]
fn test_validate_circular_dependency_self_reference() {
let mut schema = VariableSchema::new();
schema.aggregates.insert(
"A".to_string(),
AggregateDefinition {
name: "A".to_string(),
unit: "units".to_string(),
grid_type: GridType::Scalar,
operation: AggregateOp::Sum,
contributors: vec!["A".to_string()],
},
);
let err = schema.validate().unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("Circular dependency"),
"Expected AggregateCircularDependency error, got: {}",
msg
);
}
#[test]
fn test_validate_circular_dependency_indirect() {
let mut schema = VariableSchema::new();
schema.aggregates.insert(
"A".to_string(),
AggregateDefinition {
name: "A".to_string(),
unit: "units".to_string(),
grid_type: GridType::Scalar,
operation: AggregateOp::Sum,
contributors: vec!["B".to_string()],
},
);
schema.aggregates.insert(
"B".to_string(),
AggregateDefinition {
name: "B".to_string(),
unit: "units".to_string(),
grid_type: GridType::Scalar,
operation: AggregateOp::Sum,
contributors: vec!["C".to_string()],
},
);
schema.aggregates.insert(
"C".to_string(),
AggregateDefinition {
name: "C".to_string(),
unit: "units".to_string(),
grid_type: GridType::Scalar,
operation: AggregateOp::Sum,
contributors: vec!["A".to_string()],
},
);
let err = schema.validate().unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("Circular dependency"),
"Expected AggregateCircularDependency error, got: {}",
msg
);
}
#[test]
fn test_validate_diamond_dependency_no_cycle() {
let schema = VariableSchema::new()
.variable("X", "units")
.aggregate("B", "units", AggregateOp::Sum)
.from("X")
.build()
.aggregate("C", "units", AggregateOp::Sum)
.from("X")
.build()
.aggregate("A", "units", AggregateOp::Sum)
.from("B")
.from("C")
.build();
assert!(schema.validate().is_ok());
}
#[test]
fn test_validate_aggregate_referencing_aggregate() {
let schema = VariableSchema::new()
.variable("X", "units")
.aggregate("Inner", "units", AggregateOp::Sum)
.from("X")
.build()
.aggregate("Outer", "units", AggregateOp::Sum)
.from("Inner")
.build();
assert!(schema.validate().is_ok());
}
#[test]
fn test_compute_aggregate_sum() {
let values = vec![1.0, 2.0, 3.0, 4.0];
let result = compute_aggregate(&values, &AggregateOp::Sum);
assert_eq!(result, 10.0);
}
#[test]
fn test_compute_aggregate_sum_with_nan() {
let values = vec![1.0, f64::NAN, 3.0, 4.0];
let result = compute_aggregate(&values, &AggregateOp::Sum);
assert_eq!(result, 8.0); }
#[test]
fn test_compute_aggregate_sum_all_nan() {
let values = vec![f64::NAN, f64::NAN];
let result = compute_aggregate(&values, &AggregateOp::Sum);
assert!(result.is_nan());
}
#[test]
fn test_compute_aggregate_sum_empty() {
let values: Vec<f64> = vec![];
let result = compute_aggregate(&values, &AggregateOp::Sum);
assert!(result.is_nan());
}
#[test]
fn test_compute_aggregate_mean() {
let values = vec![1.0, 2.0, 3.0, 4.0];
let result = compute_aggregate(&values, &AggregateOp::Mean);
assert_eq!(result, 2.5); }
#[test]
fn test_compute_aggregate_mean_with_nan() {
let values = vec![1.0, f64::NAN, 3.0];
let result = compute_aggregate(&values, &AggregateOp::Mean);
assert_eq!(result, 2.0); }
#[test]
fn test_compute_aggregate_mean_all_nan() {
let values = vec![f64::NAN, f64::NAN];
let result = compute_aggregate(&values, &AggregateOp::Mean);
assert!(result.is_nan());
}
#[test]
fn test_compute_aggregate_weighted() {
let values = vec![10.0, 20.0, 30.0];
let weights = vec![0.5, 0.3, 0.2];
let result = compute_aggregate(&values, &AggregateOp::Weighted(weights));
assert_eq!(result, 17.0); }
#[test]
fn test_compute_aggregate_weighted_with_nan() {
let values = vec![10.0, f64::NAN, 30.0];
let weights = vec![0.5, 0.3, 0.2];
let result = compute_aggregate(&values, &AggregateOp::Weighted(weights));
assert_eq!(result, 11.0); }
#[test]
fn test_compute_aggregate_weighted_all_nan() {
let values = vec![f64::NAN, f64::NAN];
let weights = vec![0.6, 0.4];
let result = compute_aggregate(&values, &AggregateOp::Weighted(weights));
assert!(result.is_nan());
}
#[test]
fn test_aggregator_component_from_definition() {
let def = AggregateDefinition {
name: "Total ERF".to_string(),
unit: "W/m^2".to_string(),
grid_type: GridType::Scalar,
operation: AggregateOp::Sum,
contributors: vec!["ERF|CO2".to_string(), "ERF|CH4".to_string()],
};
let agg = AggregatorComponent::from_definition(&def);
assert_eq!(agg.aggregate_name, "Total ERF");
assert_eq!(agg.unit, "W/m^2");
assert_eq!(agg.grid_type, GridType::Scalar);
assert_eq!(agg.operation, AggregateOp::Sum);
assert_eq!(agg.contributors, vec!["ERF|CO2", "ERF|CH4"]);
}
#[test]
fn test_aggregator_component_definitions() {
let def = AggregateDefinition {
name: "Total ERF".to_string(),
unit: "W/m^2".to_string(),
grid_type: GridType::Scalar,
operation: AggregateOp::Sum,
contributors: vec!["ERF|CO2".to_string(), "ERF|CH4".to_string()],
};
let agg = AggregatorComponent::from_definition(&def);
let defs = agg.definitions();
assert_eq!(defs.len(), 3);
assert_eq!(defs[0].name, "ERF|CO2");
assert_eq!(defs[0].requirement_type, RequirementType::Input);
assert_eq!(defs[1].name, "ERF|CH4");
assert_eq!(defs[1].requirement_type, RequirementType::Input);
assert_eq!(defs[2].name, "Total ERF");
assert_eq!(defs[2].requirement_type, RequirementType::Output);
}
#[test]
fn test_aggregator_component_serialization() {
let def = AggregateDefinition {
name: "Total".to_string(),
unit: "units".to_string(),
grid_type: GridType::Scalar,
operation: AggregateOp::Mean,
contributors: vec!["A".to_string(), "B".to_string()],
};
let agg = AggregatorComponent::from_definition(&def);
let json = serde_json::to_string(&agg).unwrap();
let deserialized: AggregatorComponent = serde_json::from_str(&json).unwrap();
assert_eq!(agg.aggregate_name, deserialized.aggregate_name);
assert_eq!(agg.operation, deserialized.operation);
assert_eq!(agg.contributors, deserialized.contributors);
}
#[test]
fn test_deserialize_valid_schema_json() {
let json = r#"{
"variables": {
"ERF|CO2": {"name": "ERF|CO2", "unit": "W/m^2", "grid_type": "Scalar"},
"ERF|CH4": {"name": "ERF|CH4", "unit": "W/m^2", "grid_type": "Scalar"}
},
"aggregates": {
"Total ERF": {
"name": "Total ERF",
"unit": "W/m^2",
"grid_type": "Scalar",
"operation": "Sum",
"contributors": ["ERF|CO2", "ERF|CH4"]
}
}
}"#;
let schema: VariableSchema = serde_json::from_str(json).unwrap();
assert_eq!(schema.variables.len(), 2);
assert_eq!(schema.aggregates.len(), 1);
}
#[test]
fn test_deserialize_invalid_schema_undefined_contributor() {
let json = r#"{
"variables": {
"ERF|CO2": {"name": "ERF|CO2", "unit": "W/m^2", "grid_type": "Scalar"}
},
"aggregates": {
"Total ERF": {
"name": "Total ERF",
"unit": "W/m^2",
"grid_type": "Scalar",
"operation": "Sum",
"contributors": ["ERF|CO2", "ERF|CH4"]
}
}
}"#;
let result: Result<VariableSchema, _> = serde_json::from_str(json);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("Undefined contributor"),
"Expected undefined contributor error, got: {}",
err
);
assert!(
err.contains("ERF|CH4"),
"Error should mention missing contributor: {}",
err
);
}
#[test]
fn test_deserialize_invalid_schema_unit_mismatch() {
let json = r#"{
"variables": {
"ERF|CO2": {"name": "ERF|CO2", "unit": "W/m^2", "grid_type": "Scalar"},
"Emissions|CO2": {"name": "Emissions|CO2", "unit": "GtCO2/yr", "grid_type": "Scalar"}
},
"aggregates": {
"Total": {
"name": "Total",
"unit": "W/m^2",
"grid_type": "Scalar",
"operation": "Sum",
"contributors": ["ERF|CO2", "Emissions|CO2"]
}
}
}"#;
let result: Result<VariableSchema, _> = serde_json::from_str(json);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("Unit mismatch"),
"Expected unit mismatch error, got: {}",
err
);
}
#[test]
fn test_deserialize_invalid_schema_circular_dependency() {
let json = r#"{
"variables": {},
"aggregates": {
"A": {
"name": "A",
"unit": "units",
"grid_type": "Scalar",
"operation": "Sum",
"contributors": ["B"]
},
"B": {
"name": "B",
"unit": "units",
"grid_type": "Scalar",
"operation": "Sum",
"contributors": ["A"]
}
}
}"#;
let result: Result<VariableSchema, _> = serde_json::from_str(json);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("Circular dependency"),
"Expected circular dependency error, got: {}",
err
);
}
#[test]
fn test_deserialize_invalid_schema_weight_count_mismatch() {
let json = r#"{
"variables": {
"A": {"name": "A", "unit": "units", "grid_type": "Scalar"},
"B": {"name": "B", "unit": "units", "grid_type": "Scalar"},
"C": {"name": "C", "unit": "units", "grid_type": "Scalar"}
},
"aggregates": {
"Total": {
"name": "Total",
"unit": "units",
"grid_type": "Scalar",
"operation": {"Weighted": [0.5, 0.5]},
"contributors": ["A", "B", "C"]
}
}
}"#;
let result: Result<VariableSchema, _> = serde_json::from_str(json);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("Weight count mismatch"),
"Expected weight count mismatch error, got: {}",
err
);
}
#[test]
fn test_deserialize_valid_schema_toml() {
let toml = r#"
[variables."ERF|CO2"]
name = "ERF|CO2"
unit = "W/m^2"
grid_type = "Scalar"
[aggregates.Total]
name = "Total"
unit = "W/m^2"
grid_type = "Scalar"
operation = "Sum"
contributors = ["ERF|CO2"]
"#;
let schema: VariableSchema = toml::from_str(toml).unwrap();
assert_eq!(schema.variables.len(), 1);
assert_eq!(schema.aggregates.len(), 1);
}
#[test]
fn test_deserialize_invalid_schema_toml_undefined_contributor() {
let toml = r#"
[variables]
[aggregates.Total]
name = "Total"
unit = "W/m^2"
grid_type = "Scalar"
operation = "Sum"
contributors = ["Missing"]
"#;
let result: Result<VariableSchema, _> = toml::from_str(toml);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("Undefined contributor"),
"Expected undefined contributor error, got: {}",
err
);
}
}