Files
gridcraft/crates/engine/src/cmd/whatif.rs
T

435 lines
18 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! What-If Analysis (Data › Forecast): Goal Seek, Scenario Manager, Data Tables; and the Data
//! Tools that work on whole lists: Advanced Filter and Consolidate.
//!
//! Every what-if evaluation is a *trial*: the workbook is cloned (cheap — sheets and cell bands
//! are copy-on-write), the input cells are overwritten, and only their dependents are
//! recalculated with the document's dependency graph. The document itself only changes when a
//! command writes its final answer (one undo step).
use std::collections::{HashMap, HashSet};
use gridcraft_calc::Calc;
use gridcraft_core::{CellRef, RangeRef, Value};
use gridcraft_model::{CalcMode, Cell, DefinedName, Sheet, StyleId, Workbook};
use serde::{Deserialize, Serialize};
use serde_json::{Value as Json, json};
use super::{CommandSpec, DocState, EngineError, Result, Session, bad, bool_param, cmd, edit, f64_param, has_doc, split_sheet, str_param};
mod advanced_filter;
mod consolidate;
mod scenarios;
use advanced_filter::advanced_filter;
#[cfg(test)]
pub(crate) use advanced_filter::criterion_matches;
use consolidate::consolidate;
#[cfg(test)]
pub(crate) use scenarios::{SCENARIO_NAME, load_scenarios};
use scenarios::{scenario_add, scenario_delete, scenario_list, scenario_show, scenario_summary};
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"data.goalSeek",
"Goal Seek…",
["Data", "Forecast", "What-If Analysis"],
None,
"{set: \"B5\" (formula cell), to: number, changing: \"B2\" (constant cell)} → {value, result, iterations, converged}. Secant search with bisection once the answer is bracketed; stops within Maximum Change (0.001) or after Maximum Iterations (100) from File › Options › Formulas. Writes the found value into `changing`.",
has_doc,
goal_seek
),
cmd!(
query "data.scenarioManager",
"Scenario Manager…",
["Data", "Forecast", "What-If Analysis"],
None,
"{sheet?} → {scenarios: [{name, sheet, changing, values, comment}]} (scenarios of the active sheet; `sheet: \"*\"` lists all)",
has_doc,
scenario_list
),
cmd!(
"data.scenarioAdd",
"Add Scenario",
[],
None,
"{name, changing: \"B2:B4\" or \"B2,B4\" (≤32 cells, active sheet), values?: [..] (default: the cells' current values), comment?, replace?: bool}",
has_doc,
scenario_add
),
cmd!("data.scenarioShow", "Show Scenario", [], None, "{name} writes the scenario's values into its changing cells", has_doc, scenario_show),
cmd!("data.scenarioDelete", "Delete Scenario", [], None, "{name}", has_doc, scenario_delete),
cmd!(
"data.scenarioSummary",
"Scenario Summary",
[],
None,
"{resultCells?: \"B10\" or \"B10:B12,D4\"} inserts a \"Scenario Summary\" sheet: changing cells and result cells for the current values and each scenario",
has_doc,
scenario_summary
),
cmd!(
"data.dataTable",
"Data Table…",
["Data", "Forecast", "What-If Analysis"],
None,
"{range: \"D2:E10\", rowInput?: \"B1\", columnInput?: \"B2\"} one-variable (values down the first column with columnInput, or across the top row with rowInput; formulas in the other edge) or two-variable (formula in the top-left corner). Computes every result by substitution and writes static values (a snapshot, not a live {=TABLE()}); ≤100,000 cells.",
has_doc,
data_table
),
cmd!(
"data.advancedFilter",
"Advanced…",
["Data", "Sort & Filter"],
None,
"{range? (list with headers; default current region), criteria?: \"H1:I3\" (omitted/blank matches all rows; header row of field names; rows OR'ed, columns AND'ed; \">100\", \"=abc\", \"a*\", \"<>\"; blank-headed formula cells are computed criteria for the first data row), copyTo?: \"K1\" (else filter in place by hiding rows), unique?: bool}",
has_doc,
advanced_filter
),
cmd!(
"data.consolidate",
"Consolidate…",
["Data", "Data Tools"],
None,
"{sources: [\"Sheet2!A1:C10\", ..], destination?: \"A1\" (default active cell), function?: sum|count|countNums|average|max|min|product, topRow?: bool, leftColumn?: bool (consolidate by labels; else by position)}",
has_doc,
consolidate
),
]
}
// ---------------------------------------------------------------- parameters
/// Resolves an optional sheet name (else `default`).
fn sheet_named(wb: &Workbook, name: Option<String>, default: usize, cmd: &str) -> Result<usize> {
match name {
Some(n) => wb.sheet_index(&n).ok_or_else(|| bad(cmd, format!("no sheet named `{n}`"))),
None => Ok(default),
}
}
/// A `"B5"` / `"Sheet2!B5"` parameter.
fn cell_arg(s: &Session, p: &Json, key: &str, cmd: &str) -> Result<(usize, CellRef)> {
let d = s.doc()?;
let t = str_param(p, key).ok_or_else(|| bad(cmd, format!("missing `{key}`")))?;
let (sh, body) = split_sheet(t);
let si = sheet_named(&d.wb, sh, d.wb.active_sheet(), cmd)?;
let c = CellRef::parse(body.trim()).ok_or_else(|| bad(cmd, format!("`{key}` is not a cell: `{t}`")))?;
Ok((si, c))
}
/// A range text (`"A1:C9"`, `"Sheet2!A:C"`) resolved against the workbook. Whole rows/columns
/// are trimmed to the used range.
pub(crate) fn parse_range(wb: &Workbook, t: &str, cmd: &str) -> Result<(usize, RangeRef)> {
let (sh, body) = split_sheet(t);
let si = sheet_named(wb, sh, wb.active_sheet(), cmd)?;
let r = RangeRef::parse(body.trim()).ok_or_else(|| bad(cmd, format!("not a range: `{t}`")))?;
Ok((si, trim_to_used(wb, si, r)))
}
fn trim_to_used(wb: &Workbook, si: usize, r: RangeRef) -> RangeRef {
if !(r.is_full_cols() || r.is_full_rows()) {
return r;
}
match wb.sheet(si).and_then(|s| s.used_range()) {
Some(u) => r.intersection(&RangeRef::new(CellRef::new(0, 0), u.end)).unwrap_or(RangeRef::cell(r.start)),
None => RangeRef::cell(r.start),
}
}
fn range_arg(s: &Session, p: &Json, key: &str, cmd: &str) -> Result<(usize, RangeRef)> {
let d = s.doc()?;
let t = str_param(p, key).ok_or_else(|| bad(cmd, format!("missing `{key}`")))?;
parse_range(&d.wb, t, cmd)
}
/// Cells of `"B2:B4,D1"` (row-major per area, duplicates removed), at most `cap`.
fn cell_list(wb: &Workbook, t: &str, cmd: &str, cap: usize) -> Result<(usize, Vec<CellRef>)> {
let mut sheet = None;
let mut out: Vec<CellRef> = Vec::new();
for part in t.split(',').map(str::trim).filter(|x| !x.is_empty()) {
let (si, r) = parse_range(wb, part, cmd)?;
if *sheet.get_or_insert(si) != si {
return Err(bad(cmd, "all cells must be on one sheet"));
}
if r.count() > cap as u64 {
return Err(bad(cmd, format!("too many cells (at most {cap})")));
}
for c in r.iter() {
if !out.contains(&c) {
out.push(c);
}
}
if out.len() > cap {
return Err(bad(cmd, format!("too many cells (at most {cap})")));
}
}
Ok((sheet.unwrap_or(wb.active_sheet()), out))
}
/// What a JSON value typed into a cell is, as a constant.
fn json_value(v: &Json, wb: &Workbook) -> Value {
match v {
Json::Null => Value::Empty,
Json::Bool(b) => Value::Bool(*b),
Json::Number(n) => n.as_f64().map(Value::number).unwrap_or_default(),
Json::String(s) if s.is_empty() => Value::Empty,
Json::String(s) => gridcraft_core::parse::parse_input(s, wb.date_system).value,
other => Value::text(other.to_string()),
}
}
fn value_json(v: &Value) -> Json {
match v {
Value::Empty => Json::Null,
Value::Number(n) => json!(n),
Value::Bool(b) => json!(b),
Value::Text(t) => json!(t.as_ref()),
Value::Error(e) => json!(e.as_str()),
Value::Array(a) => value_json(&a.data.first().cloned().unwrap_or_default()),
}
}
// ---------------------------------------------------------------- trials
/// Evaluates a workbook with some input cells replaced, without touching the document.
pub(crate) struct Trial {
base: Workbook,
calc: Calc,
}
impl Trial {
pub(crate) fn new(d: &DocState) -> Trial {
let mut base = (*d.wb).clone();
// What-if analysis always recalculates, even in manual mode.
base.calc.mode = CalcMode::Automatic;
Trial { base, calc: d.calc.clone() }
}
/// Sets `sets` to constants, recalculates their dependents and reads `reads`.
pub(crate) fn run(&mut self, sets: &[(usize, CellRef, Value)], reads: &[(usize, CellRef)]) -> Vec<Value> {
let mut wb = self.base.clone();
let mut keys = Vec::with_capacity(sets.len());
for (si, c, v) in sets {
if let Some(sh) = wb.sheet_mut(*si) {
let style = sh.style_id(*c);
sh.set_cell(*c, Cell { value: v.clone(), formula: None, style });
keys.push((*si, *c));
}
}
self.calc.cells_changed(&mut wb, &keys);
reads.iter().map(|(si, c)| wb.sheet(*si).map(|s| s.value(*c)).unwrap_or_default()).collect()
}
}
// ---------------------------------------------------------------- goal seek
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct Seek {
pub x: f64,
pub fx: f64,
pub iterations: u32,
pub converged: bool,
}
/// Finds `x` with `|f(x)| <= tol`: secant steps (with growing steps while nothing is
/// bracketed), switching to secant-or-bisection inside a sign-change bracket. Returns the best
/// point seen. `f` returns NaN where the model errors.
pub(crate) fn seek(x0: f64, f: &mut dyn FnMut(f64) -> f64, max_iter: u32, tol: f64) -> Seek {
let tol = if tol.is_finite() && tol > 0.0 { tol } else { 0.001 };
let max_iter = max_iter.clamp(1, 10_000);
let x0 = if x0.is_finite() { x0 } else { 0.0 };
let f0 = f(x0);
let mut best = (x0, f0);
let consider = |best: &mut (f64, f64), x: f64, fx: f64| {
if fx.is_finite() && (!best.1.is_finite() || fx.abs() < best.1.abs()) {
*best = (x, fx);
}
};
let done = |best: &(f64, f64), it: u32| Seek { x: best.0, fx: best.1, iterations: it, converged: best.1.is_finite() && best.1.abs() <= tol };
if f0.is_finite() && f0.abs() <= tol {
return done(&best, 0);
}
let step0 = if x0 == 0.0 { 0.01 } else { x0.abs() * 0.01 };
let (mut xa, mut fa) = (x0, f0);
let mut xb = x0 + step0;
let mut fb = f(xb);
let mut it = 1u32;
consider(&mut best, xb, fb);
// (lo, f(lo), hi, f(hi)) with a sign change.
let mut bracket: Option<(f64, f64, f64, f64)> = None;
let mut slow = 0u32;
while it < max_iter {
if fb.is_finite() && fb.abs() <= tol {
break;
}
if bracket.is_none() && fa.is_finite() && fb.is_finite() && (fa < 0.0) != (fb < 0.0) {
bracket = Some((xa, fa, xb, fb));
}
let secant = if fa.is_finite() && fb.is_finite() && fb != fa { xb - fb * (xb - xa) / (fb - fa) } else { f64::NAN };
let next = match bracket {
Some((lo, _, hi, _)) => {
let (a, b) = (lo.min(hi), lo.max(hi));
if slow >= 2 || !(secant > a && secant < b) { (lo + hi) / 2.0 } else { secant }
}
None => {
let span = (xb - xa).abs().max(f64::MIN_POSITIVE);
if !fb.is_finite() {
// The model errors here: step back towards the last good point.
(xa + xb) / 2.0
} else if !secant.is_finite() {
xb + 2.0 * (xb - xa)
} else if (secant - xb).abs() > 100.0 * span + 1.0 {
xb + (secant - xb).signum() * (100.0 * span + 1.0)
} else {
secant
}
}
};
if !next.is_finite() {
break;
}
let fnext = f(next);
it += 1;
consider(&mut best, next, fnext);
if let Some((lo, flo, hi, fhi)) = bracket {
let width = (hi - lo).abs();
if fnext.is_finite() {
bracket = Some(if (fnext < 0.0) == (flo < 0.0) { (next, fnext, hi, fhi) } else { (lo, flo, next, fnext) });
} else {
// An error inside the bracket: shrink towards the better end.
bracket = Some(if flo.abs() < fhi.abs() { (lo, flo, next, fhi) } else { (next, flo, hi, fhi) });
}
if let Some((l, _, h, _)) = bracket {
let nw = (h - l).abs();
slow = if nw > width * 0.5 { slow + 1 } else { 0 };
if nw <= f64::EPSILON * (1.0 + l.abs().max(h.abs())) {
break;
}
}
}
xa = xb;
fa = fb;
xb = next;
fb = fnext;
}
done(&best, it)
}
fn goal_seek(s: &mut Session, p: &Json) -> Result<Json> {
const CMD: &str = "data.goalSeek";
let (ssi, set) = cell_arg(s, p, "set", CMD)?;
let (csi, chg) = cell_arg(s, p, "changing", CMD)?;
let target = f64_param(p, "to")
.or_else(|| str_param(p, "to").and_then(gridcraft_core::parse::parse_number_text))
.filter(|v| v.is_finite())
.ok_or_else(|| bad(CMD, "`to` must be a number"))?;
let d = s.doc()?;
let has_formula = |si: usize, c: CellRef| d.wb.sheet(si).and_then(|sh| sh.cell(c)).is_some_and(|c| c.formula.is_some());
if !has_formula(ssi, set) {
return Err(EngineError::Other("Cell must contain a formula.".into()));
}
if has_formula(csi, chg) {
return Err(EngineError::Other("Cell must contain a value.".into()));
}
let x0 = d.wb.sheet(csi).map(|sh| sh.value(chg)).and_then(|v| v.as_f64()).unwrap_or(0.0);
let (max_iter, tol) = (d.wb.calc.max_iterations, d.wb.calc.max_change);
let mut trial = Trial::new(d);
let mut f = |x: f64| -> f64 {
trial.run(&[(csi, chg, Value::number(x))], &[(ssi, set)]).first().and_then(Value::as_f64).map(|v| v - target).unwrap_or(f64::NAN)
};
let r = seek(x0, &mut f, max_iter, tol);
edit(s, |cx| {
let sh = cx.sheet_mut(csi)?;
let style = sh.style_id(chg);
sh.set_cell(chg, Cell { value: Value::number(r.x), formula: None, style });
cx.touch(csi, chg);
Ok(())
})?;
let result = s.doc()?.wb.sheet(ssi).map(|sh| sh.value(set)).unwrap_or_default();
Ok(json!({"value": r.x, "result": value_json(&result), "iterations": r.iterations, "converged": r.converged}))
}
// ---------------------------------------------------------------- data tables
const MAX_TABLE_CELLS: u64 = 100_000;
fn data_table(s: &mut Session, p: &Json) -> Result<Json> {
const CMD: &str = "data.dataTable";
let (si, r) = range_arg(s, p, "range", CMD)?;
let row_in = if p.get("rowInput").and_then(Json::as_str).is_some_and(|t| !t.is_empty()) { Some(cell_arg(s, p, "rowInput", CMD)?) } else { None };
let col_in =
if p.get("columnInput").and_then(Json::as_str).is_some_and(|t| !t.is_empty()) { Some(cell_arg(s, p, "columnInput", CMD)?) } else { None };
if row_in.is_none() && col_in.is_none() {
return Err(bad(CMD, "give `rowInput`, `columnInput` or both"));
}
if r.height() < 2 || r.width() < 2 {
return Err(bad(CMD, "the table needs at least two rows and two columns"));
}
let body = RangeRef::new(CellRef::new(r.start.row + 1, r.start.col + 1), r.end);
if body.count() > MAX_TABLE_CELLS {
return Err(bad(CMD, format!("the table body is too large ({MAX_TABLE_CELLS} cells max)")));
}
for input in [row_in, col_in].into_iter().flatten() {
if input.0 == si && r.contains(input.1) {
return Err(EngineError::Other("Input cell reference is not valid.".into()));
}
}
let d = s.doc()?;
let sh = d.wb.sheet(si).ok_or(EngineError::NoDocument)?;
let (r0, c0) = (r.start.row, r.start.col);
let mut trial = Trial::new(d);
let mut out: Vec<(CellRef, Value)> = Vec::with_capacity(body.count() as usize);
let mode = match (row_in, col_in) {
(None, Some((isi, ic))) => {
let reads: Vec<(usize, CellRef)> = (c0 + 1..=r.end.col).map(|c| (si, CellRef::new(r0, c))).collect();
for row in r0 + 1..=r.end.row {
let v = sh.value(CellRef::new(row, c0));
let vals = trial.run(&[(isi, ic, v)], &reads);
for (k, val) in vals.into_iter().enumerate() {
out.push((CellRef::new(row, c0 + 1 + k as u32), val));
}
}
"column"
}
(Some((isi, ic)), None) => {
let reads: Vec<(usize, CellRef)> = (r0 + 1..=r.end.row).map(|row| (si, CellRef::new(row, c0))).collect();
for col in c0 + 1..=r.end.col {
let v = sh.value(CellRef::new(r0, col));
let vals = trial.run(&[(isi, ic, v)], &reads);
for (k, val) in vals.into_iter().enumerate() {
out.push((CellRef::new(r0 + 1 + k as u32, col), val));
}
}
"row"
}
(Some((rsi, rc)), Some((csi, cc))) => {
let reads = [(si, CellRef::new(r0, c0))];
for row in r0 + 1..=r.end.row {
let cv = sh.value(CellRef::new(row, c0));
for col in c0 + 1..=r.end.col {
let rv = sh.value(CellRef::new(r0, col));
let vals = trial.run(&[(rsi, rc, rv), (csi, cc, cv.clone())], &reads);
out.push((CellRef::new(row, col), vals.into_iter().next().unwrap_or_default()));
}
}
"twoVariable"
}
(None, None) => return Err(bad(CMD, "give `rowInput`, `columnInput` or both")),
};
let n = out.len();
edit(s, |cx| {
for (c, v) in out {
let sh = cx.sheet_mut(si)?;
let style = sh.style_id(c);
sh.set_cell(c, Cell { value: v, formula: None, style });
cx.touch(si, c);
}
Ok(())
})?;
Ok(json!({"range": body.a1(), "cells": n, "mode": mode, "static": true}))
}
#[cfg(test)]
mod tests;