feat: add Axis::None for hidden dimensions with implicit aggregation
Categories on the None axis are excluded from the grid and cell keys. When evaluating cells, values across hidden dimensions are aggregated using a per-measure function (default SUM). Adds evaluate_aggregated to Model, none_cats to GridLayout, and 'n' shortcut in TileSelect. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@ -1,10 +1,12 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use indexmap::IndexMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::category::{Category, CategoryId};
|
||||
use super::cell::{CellKey, CellValue, DataStore};
|
||||
use crate::formula::Formula;
|
||||
use crate::formula::{AggFunc, Formula};
|
||||
use crate::view::View;
|
||||
|
||||
const MAX_CATEGORIES: usize = 12;
|
||||
@ -18,6 +20,10 @@ pub struct Model {
|
||||
pub views: IndexMap<String, View>,
|
||||
pub active_view: String,
|
||||
next_category_id: CategoryId,
|
||||
/// Per-measure aggregation function (measure item name → agg func).
|
||||
/// Used when collapsing categories on `Axis::None`. Defaults to SUM.
|
||||
#[serde(default)]
|
||||
pub measure_agg: HashMap<String, AggFunc>,
|
||||
}
|
||||
|
||||
impl Model {
|
||||
@ -34,6 +40,7 @@ impl Model {
|
||||
views,
|
||||
active_view: "Default".to_string(),
|
||||
next_category_id: 0,
|
||||
measure_agg: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@ -172,6 +179,67 @@ impl Model {
|
||||
self.evaluate(key).and_then(|v| v.as_f64()).unwrap_or(0.0)
|
||||
}
|
||||
|
||||
/// Evaluate a cell, aggregating over any hidden (None-axis) categories.
|
||||
/// When `none_cats` is empty, delegates to `evaluate`.
|
||||
/// Otherwise, uses `matching_cells` with the partial key and aggregates
|
||||
/// using the measure's agg function (default SUM).
|
||||
pub fn evaluate_aggregated(
|
||||
&self,
|
||||
key: &CellKey,
|
||||
none_cats: &[String],
|
||||
) -> Option<CellValue> {
|
||||
if none_cats.is_empty() {
|
||||
return self.evaluate(key);
|
||||
}
|
||||
|
||||
// Check formulas first — they handle their own aggregation
|
||||
for formula in &self.formulas {
|
||||
if let Some(item_val) = key.get(&formula.target_category) {
|
||||
if item_val == formula.target {
|
||||
return self.eval_formula(formula, key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Aggregate raw data across all None-axis categories
|
||||
let values: Vec<f64> = self
|
||||
.data
|
||||
.matching_cells(&key.0)
|
||||
.into_iter()
|
||||
.filter_map(|(_, v)| v.as_f64())
|
||||
.collect();
|
||||
|
||||
if values.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Determine agg func from measure_agg map, defaulting to SUM
|
||||
let agg = key
|
||||
.get("Measure")
|
||||
.and_then(|m| self.measure_agg.get(m))
|
||||
.unwrap_or(&AggFunc::Sum);
|
||||
|
||||
let result = match agg {
|
||||
AggFunc::Sum => values.iter().sum(),
|
||||
AggFunc::Avg => values.iter().sum::<f64>() / values.len() as f64,
|
||||
AggFunc::Min => values.iter().cloned().reduce(f64::min)?,
|
||||
AggFunc::Max => values.iter().cloned().reduce(f64::max)?,
|
||||
AggFunc::Count => values.len() as f64,
|
||||
};
|
||||
Some(CellValue::Number(result))
|
||||
}
|
||||
|
||||
/// Evaluate aggregated as f64, returning 0.0 for empty cells.
|
||||
pub fn evaluate_aggregated_f64(
|
||||
&self,
|
||||
key: &CellKey,
|
||||
none_cats: &[String],
|
||||
) -> f64 {
|
||||
self.evaluate_aggregated(key, none_cats)
|
||||
.and_then(|v| v.as_f64())
|
||||
.unwrap_or(0.0)
|
||||
}
|
||||
|
||||
fn eval_formula(&self, formula: &Formula, context: &CellKey) -> Option<CellValue> {
|
||||
use crate::formula::{AggFunc, Expr};
|
||||
|
||||
@ -490,6 +558,82 @@ mod model_tests {
|
||||
m.set_cell(k.clone(), CellValue::Number(77.0));
|
||||
assert_eq!(m.get_cell(&k), Some(&CellValue::Number(77.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evaluate_aggregated_sums_over_hidden_dimension() {
|
||||
let mut m = Model::new("Test");
|
||||
m.add_category("Payee").unwrap();
|
||||
m.add_category("Date").unwrap();
|
||||
m.add_category("Measure").unwrap();
|
||||
m.category_mut("Payee").unwrap().add_item("Acme");
|
||||
m.category_mut("Date").unwrap().add_item("Jan-01");
|
||||
m.category_mut("Date").unwrap().add_item("Jan-02");
|
||||
m.category_mut("Measure").unwrap().add_item("Amount");
|
||||
|
||||
m.set_cell(
|
||||
coord(&[("Payee", "Acme"), ("Date", "Jan-01"), ("Measure", "Amount")]),
|
||||
CellValue::Number(100.0),
|
||||
);
|
||||
m.set_cell(
|
||||
coord(&[("Payee", "Acme"), ("Date", "Jan-02"), ("Measure", "Amount")]),
|
||||
CellValue::Number(50.0),
|
||||
);
|
||||
|
||||
// Without hidden dims, returns None for partial key
|
||||
let partial_key = coord(&[("Payee", "Acme"), ("Measure", "Amount")]);
|
||||
assert_eq!(m.evaluate(&partial_key), None);
|
||||
|
||||
// With Date as hidden dimension, aggregates to SUM
|
||||
let none_cats = vec!["Date".to_string()];
|
||||
let result = m.evaluate_aggregated(&partial_key, &none_cats);
|
||||
assert_eq!(result, Some(CellValue::Number(150.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evaluate_aggregated_no_hidden_delegates_to_evaluate() {
|
||||
let mut m = Model::new("Test");
|
||||
m.add_category("Region").unwrap();
|
||||
m.category_mut("Region").unwrap().add_item("East");
|
||||
m.set_cell(
|
||||
coord(&[("Region", "East")]),
|
||||
CellValue::Number(42.0),
|
||||
);
|
||||
let key = coord(&[("Region", "East")]);
|
||||
assert_eq!(
|
||||
m.evaluate_aggregated(&key, &[]),
|
||||
Some(CellValue::Number(42.0))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evaluate_aggregated_respects_measure_agg() {
|
||||
use crate::formula::AggFunc;
|
||||
|
||||
let mut m = Model::new("Test");
|
||||
m.add_category("Payee").unwrap();
|
||||
m.add_category("Date").unwrap();
|
||||
m.add_category("Measure").unwrap();
|
||||
m.category_mut("Payee").unwrap().add_item("Acme");
|
||||
m.category_mut("Date").unwrap().add_item("D1");
|
||||
m.category_mut("Date").unwrap().add_item("D2");
|
||||
m.category_mut("Measure").unwrap().add_item("Price");
|
||||
|
||||
m.set_cell(
|
||||
coord(&[("Payee", "Acme"), ("Date", "D1"), ("Measure", "Price")]),
|
||||
CellValue::Number(10.0),
|
||||
);
|
||||
m.set_cell(
|
||||
coord(&[("Payee", "Acme"), ("Date", "D2"), ("Measure", "Price")]),
|
||||
CellValue::Number(30.0),
|
||||
);
|
||||
|
||||
m.measure_agg.insert("Price".to_string(), AggFunc::Avg);
|
||||
|
||||
let key = coord(&[("Payee", "Acme"), ("Measure", "Price")]);
|
||||
let none_cats = vec!["Date".to_string()];
|
||||
let result = m.evaluate_aggregated(&key, &none_cats);
|
||||
assert_eq!(result, Some(CellValue::Number(20.0))); // avg(10, 30) = 20
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
Reference in New Issue
Block a user