Skip to main content

ravenrs_sheet/engine/cmd/
whatif.rs

1//! What-If Analysis (Data › Forecast): Goal Seek, Scenario Manager, Data Tables; and the Data
2//! Tools that work on whole lists: Advanced Filter and Consolidate.
3//!
4//! Every what-if evaluation is a *trial*: the workbook is cloned (cheap — sheets and cell bands
5//! are copy-on-write), the input cells are overwritten, and only their dependents are
6//! recalculated with the document's dependency graph. The document itself only changes when a
7//! command writes its final answer (one undo step).
8
9use std::collections::{HashMap, HashSet};
10
11use crate::base::{CellRef, RangeRef, Value};
12use crate::calc::Calc;
13use crate::model::{CalcMode, Cell, DefinedName, Sheet, StyleId, Workbook};
14use serde::{Deserialize, Serialize};
15use serde_json::{Value as Json, json};
16
17use super::{CommandSpec, DocState, EngineError, Result, Session, bad, bool_param, cmd, edit, f64_param, has_doc, split_sheet, str_param};
18
19mod advanced_filter;
20mod consolidate;
21mod scenarios;
22
23use advanced_filter::advanced_filter;
24#[cfg(test)]
25pub(crate) use advanced_filter::criterion_matches;
26use consolidate::consolidate;
27#[cfg(test)]
28pub(crate) use scenarios::{SCENARIO_NAME, load_scenarios};
29use scenarios::{scenario_add, scenario_delete, scenario_list, scenario_show, scenario_summary};
30
31pub fn specs() -> Vec<CommandSpec> {
32    vec![
33        cmd!(
34            "data.goalSeek",
35            "Goal Seek…",
36            ["Data", "Forecast", "What-If Analysis"],
37            None,
38            "{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`.",
39            has_doc,
40            goal_seek
41        ),
42        cmd!(
43            query "data.scenarioManager",
44            "Scenario Manager…",
45            ["Data", "Forecast", "What-If Analysis"],
46            None,
47            "{sheet?} → {scenarios: [{name, sheet, changing, values, comment}]} (scenarios of the active sheet; `sheet: \"*\"` lists all)",
48            has_doc,
49            scenario_list
50        ),
51        cmd!(
52            "data.scenarioAdd",
53            "Add Scenario",
54            [],
55            None,
56            "{name, changing: \"B2:B4\" or \"B2,B4\" (≤32 cells, active sheet), values?: [..] (default: the cells' current values), comment?, replace?: bool}",
57            has_doc,
58            scenario_add
59        ),
60        cmd!("data.scenarioShow", "Show Scenario", [], None, "{name} writes the scenario's values into its changing cells", has_doc, scenario_show),
61        cmd!("data.scenarioDelete", "Delete Scenario", [], None, "{name}", has_doc, scenario_delete),
62        cmd!(
63            "data.scenarioSummary",
64            "Scenario Summary",
65            [],
66            None,
67            "{resultCells?: \"B10\" or \"B10:B12,D4\"} inserts a \"Scenario Summary\" sheet: changing cells and result cells for the current values and each scenario",
68            has_doc,
69            scenario_summary
70        ),
71        cmd!(
72            "data.dataTable",
73            "Data Table…",
74            ["Data", "Forecast", "What-If Analysis"],
75            None,
76            "{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.",
77            has_doc,
78            data_table
79        ),
80        cmd!(
81            "data.advancedFilter",
82            "Advanced…",
83            ["Data", "Sort & Filter"],
84            None,
85            "{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}",
86            has_doc,
87            advanced_filter
88        ),
89        cmd!(
90            "data.consolidate",
91            "Consolidate…",
92            ["Data", "Data Tools"],
93            None,
94            "{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)}",
95            has_doc,
96            consolidate
97        ),
98    ]
99}
100
101// ---------------------------------------------------------------- parameters
102
103/// Resolves an optional sheet name (else `default`).
104fn sheet_named(wb: &Workbook, name: Option<String>, default: usize, cmd: &str) -> Result<usize> {
105    match name {
106        Some(n) => wb.sheet_index(&n).ok_or_else(|| bad(cmd, format!("no sheet named `{n}`"))),
107        None => Ok(default),
108    }
109}
110
111/// A `"B5"` / `"Sheet2!B5"` parameter.
112fn cell_arg(s: &Session, p: &Json, key: &str, cmd: &str) -> Result<(usize, CellRef)> {
113    let d = s.doc()?;
114    let t = str_param(p, key).ok_or_else(|| bad(cmd, format!("missing `{key}`")))?;
115    let (sh, body) = split_sheet(t);
116    let si = sheet_named(&d.wb, sh, d.wb.active_sheet(), cmd)?;
117    let c = CellRef::parse(body.trim()).ok_or_else(|| bad(cmd, format!("`{key}` is not a cell: `{t}`")))?;
118    Ok((si, c))
119}
120
121/// A range text (`"A1:C9"`, `"Sheet2!A:C"`) resolved against the workbook. Whole rows/columns
122/// are trimmed to the used range.
123pub(crate) fn parse_range(wb: &Workbook, t: &str, cmd: &str) -> Result<(usize, RangeRef)> {
124    let (sh, body) = split_sheet(t);
125    let si = sheet_named(wb, sh, wb.active_sheet(), cmd)?;
126    let r = RangeRef::parse(body.trim()).ok_or_else(|| bad(cmd, format!("not a range: `{t}`")))?;
127    Ok((si, trim_to_used(wb, si, r)))
128}
129
130fn trim_to_used(wb: &Workbook, si: usize, r: RangeRef) -> RangeRef {
131    if !(r.is_full_cols() || r.is_full_rows()) {
132        return r;
133    }
134    match wb.sheet(si).and_then(|s| s.used_range()) {
135        Some(u) => r.intersection(&RangeRef::new(CellRef::new(0, 0), u.end)).unwrap_or(RangeRef::cell(r.start)),
136        None => RangeRef::cell(r.start),
137    }
138}
139
140fn range_arg(s: &Session, p: &Json, key: &str, cmd: &str) -> Result<(usize, RangeRef)> {
141    let d = s.doc()?;
142    let t = str_param(p, key).ok_or_else(|| bad(cmd, format!("missing `{key}`")))?;
143    parse_range(&d.wb, t, cmd)
144}
145
146/// Cells of `"B2:B4,D1"` (row-major per area, duplicates removed), at most `cap`.
147fn cell_list(wb: &Workbook, t: &str, cmd: &str, cap: usize) -> Result<(usize, Vec<CellRef>)> {
148    let mut sheet = None;
149    let mut out: Vec<CellRef> = Vec::new();
150    for part in t.split(',').map(str::trim).filter(|x| !x.is_empty()) {
151        let (si, r) = parse_range(wb, part, cmd)?;
152        if *sheet.get_or_insert(si) != si {
153            return Err(bad(cmd, "all cells must be on one sheet"));
154        }
155        if r.count() > cap as u64 {
156            return Err(bad(cmd, format!("too many cells (at most {cap})")));
157        }
158        for c in r.iter() {
159            if !out.contains(&c) {
160                out.push(c);
161            }
162        }
163        if out.len() > cap {
164            return Err(bad(cmd, format!("too many cells (at most {cap})")));
165        }
166    }
167    Ok((sheet.unwrap_or(wb.active_sheet()), out))
168}
169
170/// What a JSON value typed into a cell is, as a constant.
171fn json_value(v: &Json, wb: &Workbook) -> Value {
172    match v {
173        Json::Null => Value::Empty,
174        Json::Bool(b) => Value::Bool(*b),
175        Json::Number(n) => n.as_f64().map(Value::number).unwrap_or_default(),
176        Json::String(s) if s.is_empty() => Value::Empty,
177        Json::String(s) => crate::base::parse::parse_input(s, wb.date_system).value,
178        other => Value::text(other.to_string()),
179    }
180}
181
182fn value_json(v: &Value) -> Json {
183    match v {
184        Value::Empty => Json::Null,
185        Value::Number(n) => json!(n),
186        Value::Bool(b) => json!(b),
187        Value::Text(t) => json!(t.as_ref()),
188        Value::Error(e) => json!(e.as_str()),
189        Value::Array(a) => value_json(&a.data.first().cloned().unwrap_or_default()),
190    }
191}
192
193// ---------------------------------------------------------------- trials
194
195/// Evaluates a workbook with some input cells replaced, without touching the document.
196pub(crate) struct Trial {
197    base: Workbook,
198    calc: Calc,
199}
200
201impl Trial {
202    pub(crate) fn new(d: &DocState) -> Trial {
203        let mut base = (*d.wb).clone();
204        // What-if analysis always recalculates, even in manual mode.
205        base.calc.mode = CalcMode::Automatic;
206        Trial { base, calc: d.calc.clone() }
207    }
208
209    /// Sets `sets` to constants, recalculates their dependents and reads `reads`.
210    pub(crate) fn run(&mut self, sets: &[(usize, CellRef, Value)], reads: &[(usize, CellRef)]) -> Vec<Value> {
211        let mut wb = self.base.clone();
212        let mut keys = Vec::with_capacity(sets.len());
213        for (si, c, v) in sets {
214            if let Some(sh) = wb.sheet_mut(*si) {
215                let style = sh.style_id(*c);
216                sh.set_cell(*c, Cell { value: v.clone(), formula: None, style, py: None });
217                keys.push((*si, *c));
218            }
219        }
220        self.calc.cells_changed(&mut wb, &keys);
221        reads.iter().map(|(si, c)| wb.sheet(*si).map(|s| s.value(*c)).unwrap_or_default()).collect()
222    }
223}
224
225// ---------------------------------------------------------------- goal seek
226
227#[derive(Clone, Copy, Debug, PartialEq)]
228pub(crate) struct Seek {
229    pub x: f64,
230    pub fx: f64,
231    pub iterations: u32,
232    pub converged: bool,
233}
234
235/// Finds `x` with `|f(x)| <= tol`: secant steps (with growing steps while nothing is
236/// bracketed), switching to secant-or-bisection inside a sign-change bracket. Returns the best
237/// point seen. `f` returns NaN where the model errors.
238pub(crate) fn seek(x0: f64, f: &mut dyn FnMut(f64) -> f64, max_iter: u32, tol: f64) -> Seek {
239    let tol = if tol.is_finite() && tol > 0.0 { tol } else { 0.001 };
240    let max_iter = max_iter.clamp(1, 10_000);
241    let x0 = if x0.is_finite() { x0 } else { 0.0 };
242    let f0 = f(x0);
243    let mut best = (x0, f0);
244    let consider = |best: &mut (f64, f64), x: f64, fx: f64| {
245        if fx.is_finite() && (!best.1.is_finite() || fx.abs() < best.1.abs()) {
246            *best = (x, fx);
247        }
248    };
249    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 };
250    if f0.is_finite() && f0.abs() <= tol {
251        return done(&best, 0);
252    }
253    let step0 = if x0 == 0.0 { 0.01 } else { x0.abs() * 0.01 };
254    let (mut xa, mut fa) = (x0, f0);
255    let mut xb = x0 + step0;
256    let mut fb = f(xb);
257    let mut it = 1u32;
258    consider(&mut best, xb, fb);
259    // (lo, f(lo), hi, f(hi)) with a sign change.
260    let mut bracket: Option<(f64, f64, f64, f64)> = None;
261    let mut slow = 0u32;
262    while it < max_iter {
263        if fb.is_finite() && fb.abs() <= tol {
264            break;
265        }
266        if bracket.is_none() && fa.is_finite() && fb.is_finite() && (fa < 0.0) != (fb < 0.0) {
267            bracket = Some((xa, fa, xb, fb));
268        }
269        let secant = if fa.is_finite() && fb.is_finite() && fb != fa { xb - fb * (xb - xa) / (fb - fa) } else { f64::NAN };
270        let next = match bracket {
271            Some((lo, _, hi, _)) => {
272                let (a, b) = (lo.min(hi), lo.max(hi));
273                if slow >= 2 || !(secant > a && secant < b) { (lo + hi) / 2.0 } else { secant }
274            }
275            None => {
276                let span = (xb - xa).abs().max(f64::MIN_POSITIVE);
277                if !fb.is_finite() {
278                    // The model errors here: step back towards the last good point.
279                    (xa + xb) / 2.0
280                } else if !secant.is_finite() {
281                    xb + 2.0 * (xb - xa)
282                } else if (secant - xb).abs() > 100.0 * span + 1.0 {
283                    xb + (secant - xb).signum() * (100.0 * span + 1.0)
284                } else {
285                    secant
286                }
287            }
288        };
289        if !next.is_finite() {
290            break;
291        }
292        let fnext = f(next);
293        it += 1;
294        consider(&mut best, next, fnext);
295        if let Some((lo, flo, hi, fhi)) = bracket {
296            let width = (hi - lo).abs();
297            if fnext.is_finite() {
298                bracket = Some(if (fnext < 0.0) == (flo < 0.0) { (next, fnext, hi, fhi) } else { (lo, flo, next, fnext) });
299            } else {
300                // An error inside the bracket: shrink towards the better end.
301                bracket = Some(if flo.abs() < fhi.abs() { (lo, flo, next, fhi) } else { (next, flo, hi, fhi) });
302            }
303            if let Some((l, _, h, _)) = bracket {
304                let nw = (h - l).abs();
305                slow = if nw > width * 0.5 { slow + 1 } else { 0 };
306                if nw <= f64::EPSILON * (1.0 + l.abs().max(h.abs())) {
307                    break;
308                }
309            }
310        }
311        xa = xb;
312        fa = fb;
313        xb = next;
314        fb = fnext;
315    }
316    done(&best, it)
317}
318
319fn goal_seek(s: &mut Session, p: &Json) -> Result<Json> {
320    const CMD: &str = "data.goalSeek";
321    let (ssi, set) = cell_arg(s, p, "set", CMD)?;
322    let (csi, chg) = cell_arg(s, p, "changing", CMD)?;
323    let target = f64_param(p, "to")
324        .or_else(|| str_param(p, "to").and_then(crate::base::parse::parse_number_text))
325        .filter(|v| v.is_finite())
326        .ok_or_else(|| bad(CMD, "`to` must be a number"))?;
327    let d = s.doc()?;
328    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());
329    if !has_formula(ssi, set) {
330        return Err(EngineError::Other("Cell must contain a formula.".into()));
331    }
332    // A Python cell is a formula to Excel (`=PY(…)`): writing a number over it would lose its code.
333    if has_formula(csi, chg) || d.wb.sheet(csi).and_then(|sh| sh.cell(chg)).is_some_and(|c| c.py.is_some()) {
334        return Err(EngineError::Other("Cell must contain a value.".into()));
335    }
336    let x0 = d.wb.sheet(csi).map(|sh| sh.value(chg)).and_then(|v| v.as_f64()).unwrap_or(0.0);
337    let (max_iter, tol) = (d.wb.calc.max_iterations, d.wb.calc.max_change);
338    let mut trial = Trial::new(d);
339    let mut f = |x: f64| -> f64 {
340        trial.run(&[(csi, chg, Value::number(x))], &[(ssi, set)]).first().and_then(Value::as_f64).map(|v| v - target).unwrap_or(f64::NAN)
341    };
342    let r = seek(x0, &mut f, max_iter, tol);
343    edit(s, |cx| {
344        let sh = cx.sheet_mut(csi)?;
345        let style = sh.style_id(chg);
346        sh.set_cell(chg, Cell { value: Value::number(r.x), formula: None, style, py: None });
347        cx.touch(csi, chg);
348        Ok(())
349    })?;
350    let result = s.doc()?.wb.sheet(ssi).map(|sh| sh.value(set)).unwrap_or_default();
351    Ok(json!({"value": r.x, "result": value_json(&result), "iterations": r.iterations, "converged": r.converged}))
352}
353
354// ---------------------------------------------------------------- data tables
355
356const MAX_TABLE_CELLS: u64 = 100_000;
357
358fn data_table(s: &mut Session, p: &Json) -> Result<Json> {
359    const CMD: &str = "data.dataTable";
360    let (si, r) = range_arg(s, p, "range", CMD)?;
361    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 };
362    let col_in =
363        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 };
364    if row_in.is_none() && col_in.is_none() {
365        return Err(bad(CMD, "give `rowInput`, `columnInput` or both"));
366    }
367    if r.height() < 2 || r.width() < 2 {
368        return Err(bad(CMD, "the table needs at least two rows and two columns"));
369    }
370    let body = RangeRef::new(CellRef::new(r.start.row + 1, r.start.col + 1), r.end);
371    if body.count() > MAX_TABLE_CELLS {
372        return Err(bad(CMD, format!("the table body is too large ({MAX_TABLE_CELLS} cells max)")));
373    }
374    for input in [row_in, col_in].into_iter().flatten() {
375        if input.0 == si && r.contains(input.1) {
376            return Err(EngineError::Other("Input cell reference is not valid.".into()));
377        }
378    }
379    let d = s.doc()?;
380    let sh = d.wb.sheet(si).ok_or(EngineError::NoDocument)?;
381    let (r0, c0) = (r.start.row, r.start.col);
382    let mut trial = Trial::new(d);
383    let mut out: Vec<(CellRef, Value)> = Vec::with_capacity(body.count() as usize);
384    let mode = match (row_in, col_in) {
385        (None, Some((isi, ic))) => {
386            let reads: Vec<(usize, CellRef)> = (c0 + 1..=r.end.col).map(|c| (si, CellRef::new(r0, c))).collect();
387            for row in r0 + 1..=r.end.row {
388                let v = sh.value(CellRef::new(row, c0));
389                let vals = trial.run(&[(isi, ic, v)], &reads);
390                for (k, val) in vals.into_iter().enumerate() {
391                    out.push((CellRef::new(row, c0 + 1 + k as u32), val));
392                }
393            }
394            "column"
395        }
396        (Some((isi, ic)), None) => {
397            let reads: Vec<(usize, CellRef)> = (r0 + 1..=r.end.row).map(|row| (si, CellRef::new(row, c0))).collect();
398            for col in c0 + 1..=r.end.col {
399                let v = sh.value(CellRef::new(r0, col));
400                let vals = trial.run(&[(isi, ic, v)], &reads);
401                for (k, val) in vals.into_iter().enumerate() {
402                    out.push((CellRef::new(r0 + 1 + k as u32, col), val));
403                }
404            }
405            "row"
406        }
407        (Some((rsi, rc)), Some((csi, cc))) => {
408            let reads = [(si, CellRef::new(r0, c0))];
409            for row in r0 + 1..=r.end.row {
410                let cv = sh.value(CellRef::new(row, c0));
411                for col in c0 + 1..=r.end.col {
412                    let rv = sh.value(CellRef::new(r0, col));
413                    let vals = trial.run(&[(rsi, rc, rv), (csi, cc, cv.clone())], &reads);
414                    out.push((CellRef::new(row, col), vals.into_iter().next().unwrap_or_default()));
415                }
416            }
417            "twoVariable"
418        }
419        (None, None) => return Err(bad(CMD, "give `rowInput`, `columnInput` or both")),
420    };
421    let n = out.len();
422    edit(s, |cx| {
423        for (c, v) in out {
424            let sh = cx.sheet_mut(si)?;
425            let style = sh.style_id(c);
426            sh.set_cell(c, Cell { value: v, formula: None, style, py: None });
427            cx.touch(si, c);
428        }
429        Ok(())
430    })?;
431    Ok(json!({"range": body.a1(), "cells": n, "mode": mode, "static": true}))
432}
433
434#[cfg(test)]
435mod tests;