pub mod online;
pub mod reconciliation;
use scirs2_core::ndarray::{Array1, Array2};
use crate::error::{Result, TimeSeriesError};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HierarchyNode {
pub id: usize,
pub name: String,
pub parent: Option<usize>,
pub children: Vec<usize>,
}
impl HierarchyNode {
pub fn new(id: usize, name: impl Into<String>, parent: Option<usize>) -> Self {
Self {
id,
name: name.into(),
parent,
children: Vec::new(),
}
}
pub fn is_leaf(&self) -> bool {
self.children.is_empty()
}
}
#[derive(Debug, Clone, Default)]
pub struct Hierarchy {
nodes: Vec<HierarchyNode>,
}
impl Hierarchy {
pub fn new() -> Self {
Self { nodes: Vec::new() }
}
pub fn add_node(&mut self, name: impl Into<String>, parent: Option<usize>) -> Result<usize> {
if let Some(p) = parent {
if p >= self.nodes.len() {
return Err(TimeSeriesError::InvalidInput(format!(
"Parent node id {p} does not exist (only {} nodes so far)",
self.nodes.len()
)));
}
}
let id = self.nodes.len();
self.nodes.push(HierarchyNode::new(id, name, parent));
if let Some(p) = parent {
self.nodes[p].children.push(id);
}
Ok(id)
}
pub fn node(&self, id: usize) -> Option<&HierarchyNode> {
self.nodes.get(id)
}
pub fn num_nodes(&self) -> usize {
self.nodes.len()
}
pub fn bottom_level_nodes(&self) -> Vec<usize> {
self.nodes
.iter()
.filter(|n| n.is_leaf())
.map(|n| n.id)
.collect()
}
pub fn root(&self) -> Result<usize> {
let roots: Vec<usize> = self
.nodes
.iter()
.filter(|n| n.parent.is_none())
.map(|n| n.id)
.collect();
match roots.len() {
0 => Err(TimeSeriesError::InvalidInput(
"Hierarchy has no root node".to_string(),
)),
1 => Ok(roots[0]),
_ => Err(TimeSeriesError::InvalidInput(format!(
"Hierarchy has {} roots; exactly one is required",
roots.len()
))),
}
}
pub fn topological_order(&self) -> Result<Vec<usize>> {
let root = self.root()?;
let mut order = Vec::with_capacity(self.nodes.len());
let mut queue = std::collections::VecDeque::new();
queue.push_back(root);
while let Some(id) = queue.pop_front() {
order.push(id);
let node = self
.nodes
.get(id)
.ok_or_else(|| TimeSeriesError::InvalidInput(format!("Node id {id} not found")))?;
for &child in &node.children {
queue.push_back(child);
}
}
Ok(order)
}
pub fn all_nodes(&self) -> &[HierarchyNode] {
&self.nodes
}
}
pub fn summing_matrix(hierarchy: &Hierarchy) -> Result<Array2<f64>> {
let n = hierarchy.num_nodes();
if n == 0 {
return Err(TimeSeriesError::InvalidInput(
"Hierarchy has no nodes".to_string(),
));
}
let bottom_ids = hierarchy.bottom_level_nodes();
let m = bottom_ids.len();
if m == 0 {
return Err(TimeSeriesError::InvalidInput(
"Hierarchy has no leaf nodes".to_string(),
));
}
let mut col_index = vec![usize::MAX; n];
for (j, &bid) in bottom_ids.iter().enumerate() {
col_index[bid] = j;
}
let mut s = Array2::<f64>::zeros((n, m));
for node in hierarchy.all_nodes() {
if node.is_leaf() {
let j = col_index[node.id];
s[[node.id, j]] = 1.0;
} else {
let descendants = collect_bottom_descendants(hierarchy, node.id);
for bid in descendants {
let j = col_index[bid];
s[[node.id, j]] = 1.0;
}
}
}
Ok(s)
}
fn collect_bottom_descendants(hierarchy: &Hierarchy, start_id: usize) -> Vec<usize> {
let mut result = Vec::new();
let mut stack = vec![start_id];
while let Some(id) = stack.pop() {
if let Some(node) = hierarchy.node(id) {
if node.is_leaf() {
result.push(id);
} else {
stack.extend_from_slice(&node.children);
}
}
}
result
}
pub fn bottom_up_forecast(
bottom_level_forecasts: &Array2<f64>,
s: &Array2<f64>,
) -> Result<Array2<f64>> {
let (n, m_s) = (s.shape()[0], s.shape()[1]);
let (m_f, h) = (
bottom_level_forecasts.shape()[0],
bottom_level_forecasts.shape()[1],
);
if m_s != m_f {
return Err(TimeSeriesError::DimensionMismatch {
expected: m_s,
actual: m_f,
});
}
let mut result = Array2::<f64>::zeros((n, h));
for i in 0..n {
for t in 0..h {
let mut acc = 0.0_f64;
for j in 0..m_s {
acc += s[[i, j]] * bottom_level_forecasts[[j, t]];
}
result[[i, t]] = acc;
}
}
Ok(result)
}
pub fn top_down_forecast(
top_level_forecast: &Array1<f64>,
historical_proportions: &Array1<f64>,
) -> Result<Array2<f64>> {
let h = top_level_forecast.len();
let m = historical_proportions.len();
if h == 0 {
return Err(TimeSeriesError::InvalidInput(
"top_level_forecast must not be empty".to_string(),
));
}
if m == 0 {
return Err(TimeSeriesError::InvalidInput(
"historical_proportions must not be empty".to_string(),
));
}
let sum_p: f64 = historical_proportions.iter().copied().sum();
if (sum_p - 1.0_f64).abs() > 1e-6 {
return Err(TimeSeriesError::InvalidInput(format!(
"historical_proportions must sum to 1 (got {sum_p:.6})"
)));
}
for &p in historical_proportions.iter() {
if p < 0.0 {
return Err(TimeSeriesError::InvalidInput(
"historical_proportions must be non-negative".to_string(),
));
}
}
let mut result = Array2::<f64>::zeros((m, h));
for j in 0..m {
for t in 0..h {
result[[j, t]] = historical_proportions[j] * top_level_forecast[t];
}
}
Ok(result)
}
pub fn average_historical_proportions(
bottom_series: &Array2<f64>,
top_series: &Array1<f64>,
) -> Result<Array1<f64>> {
let m = bottom_series.shape()[0];
let t_len = bottom_series.shape()[1];
if top_series.len() != t_len {
return Err(TimeSeriesError::DimensionMismatch {
expected: t_len,
actual: top_series.len(),
});
}
if m == 0 || t_len == 0 {
return Err(TimeSeriesError::InvalidInput(
"bottom_series must be non-empty".to_string(),
));
}
let mut prop_sums = vec![0.0_f64; m];
let mut valid_count = 0_usize;
for t in 0..t_len {
let total = top_series[t];
if total.abs() < f64::EPSILON {
continue;
}
valid_count += 1;
for j in 0..m {
prop_sums[j] += bottom_series[[j, t]] / total;
}
}
if valid_count == 0 {
return Err(TimeSeriesError::ComputationError(
"All top-level values are zero; cannot compute proportions".to_string(),
));
}
let scale = 1.0 / valid_count as f64;
let proportions: Vec<f64> = prop_sums.iter().map(|&s| s * scale).collect();
Ok(Array1::from(proportions))
}
pub fn proportions_of_historical_averages(bottom_series: &Array2<f64>) -> Result<Array1<f64>> {
let m = bottom_series.shape()[0];
let t_len = bottom_series.shape()[1];
if m == 0 || t_len == 0 {
return Err(TimeSeriesError::InvalidInput(
"bottom_series must be non-empty".to_string(),
));
}
let means: Vec<f64> = (0..m)
.map(|j| bottom_series.row(j).iter().copied().sum::<f64>() / t_len as f64)
.collect();
let grand_mean: f64 = means.iter().copied().sum();
if grand_mean.abs() < f64::EPSILON {
return Err(TimeSeriesError::ComputationError(
"Grand mean is zero; cannot compute proportions of historical averages".to_string(),
));
}
let proportions: Vec<f64> = means.iter().map(|&m| m / grand_mean).collect();
Ok(Array1::from(proportions))
}
pub fn middle_out_forecast(
middle_level_forecasts: &Array2<f64>,
middle_node_ids: &[usize],
hierarchy: &Hierarchy,
middle_to_bottom_proportions: &Array2<f64>,
s: &Array2<f64>,
) -> Result<Array2<f64>> {
let k = middle_level_forecasts.shape()[0];
let h = middle_level_forecasts.shape()[1];
let n = s.shape()[0];
let m = s.shape()[1];
if middle_node_ids.len() != k {
return Err(TimeSeriesError::DimensionMismatch {
expected: k,
actual: middle_node_ids.len(),
});
}
if middle_to_bottom_proportions.shape()[0] != k || middle_to_bottom_proportions.shape()[1] != m
{
return Err(TimeSeriesError::InvalidInput(format!(
"middle_to_bottom_proportions shape must be ({k}, {m}), got ({}, {})",
middle_to_bottom_proportions.shape()[0],
middle_to_bottom_proportions.shape()[1]
)));
}
for i in 0..k {
let row_sum: f64 = middle_to_bottom_proportions.row(i).iter().copied().sum();
if (row_sum - 1.0_f64).abs() > 1e-6 {
return Err(TimeSeriesError::InvalidInput(format!(
"Proportions for middle node {i} sum to {row_sum:.6}, expected 1"
)));
}
}
let mut bottom_forecasts = Array2::<f64>::zeros((m, h));
for i in 0..k {
for j in 0..m {
let p = middle_to_bottom_proportions[[i, j]];
if p == 0.0 {
continue;
}
for t in 0..h {
bottom_forecasts[[j, t]] += p * middle_level_forecasts[[i, t]];
}
}
}
let all_forecasts = bottom_up_forecast(&bottom_forecasts, s)?;
let mut result = all_forecasts;
for (i, &nid) in middle_node_ids.iter().enumerate() {
if nid >= n {
return Err(TimeSeriesError::InvalidInput(format!(
"Middle node id {nid} exceeds hierarchy size {n}"
)));
}
for t in 0..h {
result[[nid, t]] = middle_level_forecasts[[i, t]];
}
}
let topo_order = hierarchy.topological_order()?;
for &id in &topo_order {
let node = hierarchy
.node(id)
.ok_or_else(|| TimeSeriesError::InvalidInput(format!("Node id {id} not found")))?;
if node.is_leaf() {
continue;
}
let children_all_middle = node.children.iter().all(|c| middle_node_ids.contains(c));
if children_all_middle {
for t in 0..h {
let mut s_val = 0.0_f64;
for &c in &node.children {
s_val += result[[c, t]];
}
result[[id, t]] = s_val;
}
}
}
Ok(result)
}
#[cfg(test)]
mod tests {
use super::*;
use scirs2_core::ndarray::array;
fn build_two_level() -> Hierarchy {
let mut h = Hierarchy::new();
let total = h.add_node("Total", None).expect("failed to create total");
let a = h.add_node("A", Some(total)).expect("failed to create a");
let b = h.add_node("B", Some(total)).expect("failed to create b");
h.add_node("A1", Some(a)).expect("unexpected None or Err");
h.add_node("A2", Some(a)).expect("unexpected None or Err");
h.add_node("B1", Some(b)).expect("unexpected None or Err");
h.add_node("B2", Some(b)).expect("unexpected None or Err");
h
}
#[test]
fn test_hierarchy_structure() {
let h = build_two_level();
assert_eq!(h.num_nodes(), 7);
assert_eq!(h.bottom_level_nodes(), vec![3, 4, 5, 6]);
assert_eq!(h.root().expect("unexpected None or Err"), 0);
}
#[test]
fn test_summing_matrix_shape() {
let h = build_two_level();
let s = summing_matrix(&h).expect("failed to create s");
assert_eq!(s.shape(), &[7, 4]);
}
#[test]
fn test_summing_matrix_values() {
let h = build_two_level();
let s = summing_matrix(&h).expect("failed to create s");
assert_eq!(s.row(0).to_vec(), vec![1.0, 1.0, 1.0, 1.0]);
assert_eq!(s.row(1).to_vec(), vec![1.0, 1.0, 0.0, 0.0]);
assert_eq!(s.row(2).to_vec(), vec![0.0, 0.0, 1.0, 1.0]);
assert_eq!(s.row(3).to_vec(), vec![1.0, 0.0, 0.0, 0.0]);
}
#[test]
fn test_bottom_up_forecast() {
let h = build_two_level();
let s = summing_matrix(&h).expect("failed to create s");
let bottom = array![[10.0, 11.0], [20.0, 22.0], [30.0, 33.0], [40.0, 44.0]];
let all = bottom_up_forecast(&bottom, &s).expect("failed to create all");
assert!((all[[0, 0]] - 100.0).abs() < 1e-9);
assert!((all[[0, 1]] - 110.0).abs() < 1e-9);
assert!((all[[1, 0]] - 30.0).abs() < 1e-9);
assert!((all[[2, 0]] - 70.0).abs() < 1e-9);
}
#[test]
fn test_top_down_forecast() {
let props = array![0.25, 0.25, 0.25, 0.25];
let top = array![100.0, 120.0];
let result = top_down_forecast(&top, &props).expect("failed to create result");
assert_eq!(result.shape(), &[4, 2]);
assert!((result[[0, 0]] - 25.0).abs() < 1e-9);
assert!((result[[0, 1]] - 30.0).abs() < 1e-9);
}
#[test]
fn test_top_down_proportions_must_sum_to_one() {
let bad_props = array![0.3, 0.3, 0.3, 0.3];
let top = array![100.0];
assert!(top_down_forecast(&top, &bad_props).is_err());
}
#[test]
fn test_proportions_of_historical_averages() {
let bottom = array![[30.0, 30.0, 30.0], [10.0, 10.0, 10.0]];
let props = proportions_of_historical_averages(&bottom).expect("failed to create props");
assert!((props[0] - 0.75).abs() < 1e-9);
assert!((props[1] - 0.25).abs() < 1e-9);
}
#[test]
fn test_average_historical_proportions() {
let bottom = array![[30.0, 30.0], [10.0, 10.0]];
let top = array![40.0, 40.0];
let props = average_historical_proportions(&bottom, &top).expect("failed to create props");
assert!((props[0] - 0.75).abs() < 1e-9);
assert!((props[1] - 0.25).abs() < 1e-9);
}
}