Skip to main content

ravenrs_sheet/engine/cmd/
forecast.rs

1//! Data › Forecast › Forecast Sheet: a new sheet with the history, a FORECAST.ETS column, the
2//! FORECAST.ETS.CONFINT confidence bounds, optional FORECAST.ETS.STAT statistics and a chart.
3//!
4//! Like Excel, the sheet holds a copy of the timeline and values (sorted by time) and live
5//! formulas over that copy, so the forecast follows edits to the copied history.
6
7use std::sync::Arc;
8
9use crate::base::{CellRef, RangeRef, Value};
10use crate::functions::Timeline;
11use crate::model::{Anchor, Cell, Chart, ChartKind, Formula, LegendPos, Series, Sheet, StyleId, Table, TableColumn, TotalsFn};
12use serde_json::{Value as Json, json};
13
14use super::{CommandSpec, EngineError, Result, Session, bad, bool_param, cmd, edit, has_doc, split_sheet, str_param};
15use crate::engine::selection::current_region;
16
17const CMD: &str = "data.forecastSheet";
18/// Most history rows a forecast sheet copies.
19const MAX_HISTORY: u64 = 10_000;
20/// Most forecast rows.
21const MAX_PERIODS: u32 = 10_000;
22/// Most history rows × forecast rows. Every forecast row holds three ETS formulas that each refit
23/// the model over the whole history (about 3 µs per history point in a release build), so this
24/// bounds a sheet's recalculation to a few seconds: 10,000 rows of history get 50 forecast rows,
25/// 50 rows of history the full 10,000.
26const MAX_WORK: u64 = 500_000;
27
28/// Forecast rows by default: a third of the history, within [`max_periods`].
29fn default_periods(history: usize) -> u32 {
30    u32::try_from(history.div_ceil(3)).unwrap_or(u32::MAX).clamp(1, max_periods(history))
31}
32
33/// Most forecast rows for `history` rows of history.
34fn max_periods(history: usize) -> u32 {
35    let h = u64::try_from(history).unwrap_or(u64::MAX).max(1);
36    u32::try_from(MAX_WORK / h).unwrap_or(u32::MAX).clamp(1, MAX_PERIODS)
37}
38
39pub fn specs() -> Vec<CommandSpec> {
40    vec![cmd!(
41        "data.forecastSheet",
42        "Forecast Sheet",
43        ["Data", "Forecast"],
44        None,
45        "{timeline?: \"A2:A25\", values?: \"B2:B25\" (default: the first two columns of `range`, the selection or the current region; a text first cell is the header), end?: timeline value or date text | periods?: n (default: a third of the history, at least 1; ≤10,000 and history × periods ≤ 500,000; history ≤ 10,000 rows), forecastStart?: a timeline value or date text on the history, before its last point (hindcast: the model is fit to the points before it, the forecast columns start there and run over the actuals; default and the last point: forecast after the history), confidenceInterval?: bool (default true), confidence?: 0.95 or 95, seasonality?: \"auto\" | 0 (none) | 2–8,760, fillMissing?: interpolate|zeros, aggregate?: average|count|counta|max|median|min|sum, statistics?: bool, chart?: line|column} → {sheet, table, chart, history, periods}. Inserts a sheet before the active one: timeline, values, Forecast (FORECAST.ETS), Lower/Upper Confidence Bound (FORECAST.ETS.CONFINT), optional FORECAST.ETS.STAT table, and a chart.",
46        has_doc,
47        forecast_sheet
48    )]
49}
50
51/// The cells of a one-row or one-column range, at most `MAX_HISTORY + 1`.
52fn line_cells(r: RangeRef) -> Result<Vec<CellRef>> {
53    if r.count() > MAX_HISTORY + 1 {
54        return Err(bad(CMD, format!("at most {MAX_HISTORY} rows of history")));
55    }
56    if r.width() == 1 {
57        Ok((r.start.row..=r.end.row).map(|row| CellRef::new(row, r.start.col)).collect())
58    } else if r.height() == 1 {
59        Ok((r.start.col..=r.end.col).map(|col| CellRef::new(r.start.row, col)).collect())
60    } else {
61        Err(bad(CMD, "the timeline and the values are each one row or one column"))
62    }
63}
64
65/// A range parameter on any sheet (whole columns trimmed to the used range).
66fn range_on(s: &Session, t: &str) -> Result<(usize, RangeRef)> {
67    let d = s.doc()?;
68    let (sh, body) = split_sheet(t);
69    let si = match sh {
70        Some(n) => d.wb.sheet_index(&n).ok_or_else(|| bad(CMD, format!("no sheet named `{n}`")))?,
71        None => d.wb.active_sheet(),
72    };
73    let r = RangeRef::parse(body.trim()).ok_or_else(|| bad(CMD, format!("not a range: `{t}`")))?;
74    let r = if r.is_full_cols() || r.is_full_rows() {
75        match d.wb.sheet(si).and_then(|s| s.used_range()) {
76            Some(u) => r.intersection(&RangeRef::new(CellRef::new(0, 0), u.end)).unwrap_or(RangeRef::cell(r.start)),
77            None => RangeRef::cell(r.start),
78        }
79    } else {
80        r
81    };
82    Ok((si, r))
83}
84
85/// (sheet, timeline cells, value cells).
86fn source(s: &Session, p: &Json) -> Result<(usize, Vec<CellRef>, Vec<CellRef>)> {
87    let text = |k: &str| str_param(p, k).map(str::trim).filter(|t| !t.is_empty());
88    match (text("timeline"), text("values")) {
89        (Some(t), Some(v)) => {
90            let (ts, tr) = range_on(s, t)?;
91            let (vs, vr) = range_on(s, v)?;
92            if ts != vs {
93                return Err(bad(CMD, "the timeline and the values must be on one sheet"));
94            }
95            Ok((ts, line_cells(tr)?, line_cells(vr)?))
96        }
97        (None, None) => {
98            let d = s.doc()?;
99            let (si, r) = match text("range") {
100                Some(t) => range_on(s, t)?,
101                None => {
102                    let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
103                    let sel = d.selection.current();
104                    (d.wb.active_sheet(), if sel.is_single() { current_region(sh, d.selection.active) } else { sel })
105                }
106            };
107            if r.width() < 2 {
108                return Err(EngineError::Other("Select a timeline column and a values column for the forecast.".into()));
109            }
110            let col = |c: u32| RangeRef::new(CellRef::new(r.start.row, c), CellRef::new(r.end.row, c));
111            Ok((si, line_cells(col(r.start.col))?, line_cells(col(r.start.col + 1))?))
112        }
113        _ => Err(bad(CMD, "give both `timeline` and `values`, or neither")),
114    }
115}
116
117/// A number parameter given as a number or as text.
118fn number(p: &Json, key: &str) -> Result<Option<f64>> {
119    match p.get(key) {
120        None | Some(Json::Null) => Ok(None),
121        Some(Json::Number(n)) => Ok(n.as_f64()),
122        Some(Json::String(t)) if t.trim().is_empty() => Ok(None),
123        Some(Json::String(t)) => {
124            t.trim().trim_end_matches('%').trim().parse::<f64>().map(Some).map_err(|_| bad(CMD, format!("`{key}` is not a number")))
125        }
126        Some(_) => Err(bad(CMD, format!("`{key}` is not a number"))),
127    }
128}
129
130struct Options {
131    seasonality: u32,
132    fill: u8,
133    aggregate: Option<u8>,
134    confidence: Option<f64>,
135    statistics: bool,
136    column_chart: bool,
137}
138
139fn options(p: &Json) -> Result<Options> {
140    let seasonality = match p.get("seasonality") {
141        Some(Json::String(t)) if t.trim().is_empty() || t.trim().eq_ignore_ascii_case("auto") => 1,
142        None | Some(Json::Null) => 1,
143        _ => match number(p, "seasonality")? {
144            Some(n) if n == n.trunc() && (0.0..=8760.0).contains(&n) => n as u32,
145            _ => return Err(bad(CMD, "`seasonality` is \"auto\", 0 (none) or a season length from 2 to 8,760")),
146        },
147    };
148    let fill = match str_param(p, "fillMissing").map(|t| t.trim().to_ascii_lowercase()) {
149        None => 1,
150        Some(t) if t.is_empty() || t == "interpolate" || t == "interpolation" => 1,
151        Some(t) if t == "zeros" || t == "zero" => 0,
152        Some(t) => return Err(bad(CMD, format!("`fillMissing` is interpolate or zeros, not `{t}`"))),
153    };
154    let aggregate = match str_param(p, "aggregate").map(|t| t.trim().to_ascii_lowercase()) {
155        None => None,
156        Some(t) => match t.as_str() {
157            "" | "average" => None,
158            "count" => Some(2),
159            "counta" => Some(3),
160            "max" => Some(4),
161            "median" => Some(5),
162            "min" => Some(6),
163            "sum" => Some(7),
164            _ => return Err(bad(CMD, format!("unknown `aggregate`: `{t}`"))),
165        },
166    };
167    let confidence = if bool_param(p, "confidenceInterval").unwrap_or(true) {
168        let c = number(p, "confidence")?.unwrap_or(0.95);
169        let c = if c >= 1.0 { c / 100.0 } else { c };
170        if !(c > 0.0 && c < 1.0) {
171            return Err(bad(CMD, "`confidence` is between 0 and 1 (or 0 and 100%)"));
172        }
173        Some(c)
174    } else {
175        None
176    };
177    let column_chart = match str_param(p, "chart").map(|t| t.trim().to_ascii_lowercase()) {
178        None => false,
179        Some(t) if t.is_empty() || t == "line" => false,
180        Some(t) if t == "column" => true,
181        Some(t) => return Err(bad(CMD, format!("`chart` is line or column, not `{t}`"))),
182    };
183    Ok(Options { seasonality, fill, aggregate, confidence, statistics: bool_param(p, "statistics").unwrap_or(false), column_chart })
184}
185
186fn col_letters(c: u32) -> String {
187    crate::base::col_to_letters(c)
188}
189
190fn forecast_sheet(s: &mut Session, p: &Json) -> Result<Json> {
191    let opts = options(p)?;
192    let (si, tcells, vcells) = source(s, p)?;
193    if tcells.len() != vcells.len() {
194        return Err(bad(CMD, "the timeline and the values need the same number of cells"));
195    }
196    let d = s.doc()?;
197    if d.wb.protected_structure {
198        return Err(EngineError::Other("The workbook is protected and cannot be changed.".into()));
199    }
200    let sys = d.wb.date_system;
201    let sh = d.wb.sheet(si).ok_or(EngineError::NoDocument)?;
202    // A text first cell is the header.
203    let header = tcells.first().is_some_and(|c| sh.value(*c).is_text()) || vcells.first().is_some_and(|c| sh.value(*c).is_text());
204    let name_of = |c: Option<&CellRef>, fallback: &str| match c.map(|c| sh.value(*c)) {
205        Some(v) if header && !v.is_empty() => v.display(),
206        _ => fallback.to_string(),
207    };
208    let (tname, vname) = (name_of(tcells.first(), "Timeline"), name_of(vcells.first(), "Values"));
209    let skip = usize::from(header);
210    let mut rows: Vec<(f64, Value)> = Vec::new();
211    for (t, v) in tcells.iter().zip(&vcells).skip(skip) {
212        let (tv, vv) = (sh.value(*t), sh.value(*v));
213        match tv {
214            Value::Number(x) => rows.push((x, if matches!(vv, Value::Number(_)) { vv } else { Value::Empty })),
215            Value::Empty if vv.is_empty() => {}
216            _ => return Err(EngineError::Other(format!("The timeline must hold numbers or dates ({} isn't one).", t.a1()))),
217        }
218    }
219    rows.sort_by(|a, b| a.0.total_cmp(&b.0));
220    let times: Vec<f64> = rows.iter().map(|r| r.0).collect();
221    let timeline =
222        Timeline::detect(&times, sys).ok_or_else(|| EngineError::Other("The timeline needs at least two points a constant step apart.".into()))?;
223    if !rows.iter().any(|r| matches!(r.1, Value::Number(_))) {
224        return Err(EngineError::Other("There are no values to forecast.".into()));
225    }
226    let time_param = |key: &str| -> Result<Option<f64>> {
227        match p.get(key) {
228            Some(Json::Number(n)) => Ok(n.as_f64()),
229            Some(Json::String(t)) if !t.trim().is_empty() => match crate::base::parse::parse_input(t.trim(), sys).value {
230                Value::Number(x) => Ok(Some(x)),
231                _ => Err(bad(CMD, format!("`{key}` is not a number or a date: `{t}`"))),
232            },
233            None | Some(Json::Null) => Ok(None),
234            Some(Json::String(_)) => Ok(None),
235            Some(_) => Err(bad(CMD, format!("`{key}` is not a number or a date"))),
236        }
237    };
238    let end = time_param("end")?;
239    // Forecast Start: the index (into `rows`) of the first forecast point, or `rows.len()` for the
240    // usual forecast after the history.
241    let fit = match time_param("forecastStart")? {
242        None => rows.len(),
243        Some(start) => {
244            let last_t = times.last().copied().unwrap_or(f64::NAN);
245            let tol = |a: f64, b: f64| (a - b).abs() <= 1e-9 * a.abs().max(b.abs()).max(1.0);
246            if tol(start, last_t) || timeline.after_last(1).is_some_and(|next| tol(start, next)) {
247                rows.len()
248            } else {
249                let i = times
250                    .iter()
251                    .position(|t| tol(*t, start))
252                    .ok_or_else(|| bad(CMD, "`forecastStart` is not on the timeline (it is a point of the history, before its last point)"))?;
253                if rows.get(..i).is_none_or(|h| h.iter().filter(|r| matches!(r.1, Value::Number(_))).count() < 2) {
254                    return Err(EngineError::Other("The forecast start needs at least two values before it to fit the forecast.".into()));
255                }
256                i
257            }
258        }
259    };
260    let cap = max_periods(rows.len());
261    let periods = match end {
262        Some(end) => steps_to(&timeline, end, cap)?,
263        None => match number(p, "periods")? {
264            Some(n) if n >= 1.0 && n.ceil() <= f64::from(cap) => n.ceil() as u32,
265            Some(_) => return Err(bad(CMD, format!("`periods` is from 1 to {cap} for {} rows of history", rows.len()))),
266            None => default_periods(rows.len()),
267        },
268    };
269    let future: Vec<f64> =
270        (1..=periods).map(|k| timeline.after_last(k)).collect::<Option<_>>().ok_or_else(|| bad(CMD, "the forecast runs past the last date"))?;
271    let fmt_of = |c: Option<&CellRef>| c.map(|c| d.wb.styles.get(sh.style_id(*c)).num_fmt.clone()).unwrap_or_default();
272    let (tfmt, vfmt) = (fmt_of(tcells.get(skip)), fmt_of(vcells.get(skip)));
273    let sheet_name = d.wb.next_sheet_name();
274    let table_name = (1..).map(|n| format!("Table{n}")).find(|n| d.wb.table(n).is_none()).unwrap_or_else(|| "Table".into());
275    let chart_id = d.wb.next_object_id();
276    let table_id = chart_id.saturating_add(1);
277
278    // Columns: A timeline, B values, C forecast, D/E bounds.
279    let n = rows.len() as u32;
280    let last = n + 1; // 1-based row of the last history point
281    let fit_last = fit as u32 + 1; // 1-based row of the last point the model is fit to
282    let end_row = last + periods; // 1-based row of the last forecast
283    let hist = |col: u32| format!("${0}$2:${0}${fit_last}", col_letters(col));
284    let mut tail = format!("{},{}", opts.seasonality, opts.fill);
285    if let Some(a) = opts.aggregate {
286        tail.push_str(&format!(",{a}"));
287    }
288    let mut headers = vec![tname, vname.clone(), format!("Forecast({vname})")];
289    if opts.confidence.is_some() {
290        headers.push(format!("Lower Confidence Bound({vname})"));
291        headers.push(format!("Upper Confidence Bound({vname})"));
292    }
293    // Table column names are unique.
294    for i in 1..headers.len() {
295        let base = headers.get(i).cloned().unwrap_or_default();
296        let mut name = base.clone();
297        let mut k = 2;
298        while headers.iter().take(i).any(|h| h.eq_ignore_ascii_case(&name)) {
299            name = format!("{base}{k}");
300            k += 1;
301        }
302        if let Some(h) = headers.get_mut(i) {
303            *h = name;
304        }
305    }
306    let width = headers.len() as u32;
307    let stats_col = width + 1;
308    let chart_col = if opts.statistics { stats_col + 3 } else { width + 1 };
309
310    let at = s.doc()?.wb.active_sheet();
311    let index = edit(s, |cx| {
312        let at = at.min(cx.wb.sheets.len());
313        let bold = cx.wb.styles.derive(StyleId::DEFAULT, |st| st.font.bold = true);
314        let tstyle = cx.wb.styles.derive(StyleId::DEFAULT, |st| st.num_fmt = tfmt.clone());
315        let vstyle = cx.wb.styles.derive(StyleId::DEFAULT, |st| st.num_fmt = vfmt.clone());
316        let mut sheet = Sheet::new(sheet_name.clone());
317        let put = |sheet: &mut Sheet, row: u32, col: u32, v: Value, style: StyleId| {
318            sheet.set_cell(CellRef::new(row, col), Cell { value: v, formula: None, style, py: None });
319        };
320        let formula = |sheet: &mut Sheet, row: u32, col: u32, text: String, style: StyleId| {
321            let mut cell = Cell::formula(Formula::new(&text));
322            cell.style = style;
323            sheet.set_cell(CellRef::new(row, col), cell);
324        };
325        for (c, h) in headers.iter().enumerate() {
326            put(&mut sheet, 0, c as u32, Value::text(h.as_str()), StyleId::DEFAULT);
327        }
328        for (i, (t, v)) in rows.iter().enumerate() {
329            let r = i as u32 + 1;
330            put(&mut sheet, r, 0, Value::Number(*t), tstyle);
331            if !v.is_empty() {
332                put(&mut sheet, r, 1, v.clone(), vstyle);
333            }
334        }
335        // The last point the model is fit to starts the forecast lines, so they join the values.
336        let join = fit_last - 1; // 0-based row
337        formula(&mut sheet, join, 2, format!("=B{fit_last}"), vstyle);
338        if opts.confidence.is_some() {
339            formula(&mut sheet, join, 3, format!("=C{fit_last}"), vstyle);
340            formula(&mut sheet, join, 4, format!("=C{fit_last}"), vstyle);
341        }
342        let (tl, vl) = (hist(0), hist(1));
343        let forecast_row = |sheet: &mut Sheet, r: u32| {
344            formula(sheet, r - 1, 2, format!("=FORECAST.ETS(A{r},{vl},{tl},{tail})"), vstyle);
345            if let Some(c) = opts.confidence {
346                let ci = format!("FORECAST.ETS.CONFINT(A{r},{vl},{tl},{c},{tail})");
347                formula(sheet, r - 1, 3, format!("=C{r}-{ci}"), vstyle);
348                formula(sheet, r - 1, 4, format!("=C{r}+{ci}"), vstyle);
349            }
350        };
351        // Hindcast: forecasts over the actuals from the start to the last history point.
352        for r in (fit_last + 1)..=last {
353            forecast_row(&mut sheet, r);
354        }
355        for (k, t) in future.iter().enumerate() {
356            let r = last + k as u32 + 1; // 1-based
357            put(&mut sheet, r - 1, 0, Value::Number(*t), tstyle);
358            forecast_row(&mut sheet, r);
359        }
360        if opts.statistics {
361            put(&mut sheet, 0, stats_col, Value::text("Statistic"), bold);
362            put(&mut sheet, 0, stats_col + 1, Value::text("Value"), bold);
363            for (k, label) in ["Alpha", "Beta", "Gamma", "MASE", "SMAPE", "MAE", "RMSE"].iter().enumerate() {
364                let r = k as u32 + 1;
365                put(&mut sheet, r, stats_col, Value::text(*label), StyleId::DEFAULT);
366                formula(&mut sheet, r, stats_col + 1, format!("=FORECAST.ETS.STAT({vl},{tl},{},{tail})", k + 1), StyleId::DEFAULT);
367            }
368        }
369        for c in 0..width {
370            sheet.cols.insert(
371                c,
372                crate::model::LineInfo {
373                    size: Some(match c {
374                        0 | 1 => 90.0,
375                        2 => 130.0,
376                        _ => 230.0,
377                    }),
378                    ..Default::default()
379                },
380            );
381        }
382        let range = RangeRef::new(CellRef::new(0, 0), CellRef::new(end_row - 1, width - 1));
383        sheet.tables.push(Table {
384            id: table_id,
385            name: table_name.clone(),
386            range,
387            header_row: true,
388            totals_row: false,
389            columns: headers
390                .iter()
391                .map(|h| TableColumn { name: h.clone(), totals: TotalsFn::None, totals_label: None, formula: None, unique_name: None, xml: None })
392                .collect(),
393            style: "TableStyleMedium2".into(),
394            banded_rows: true,
395            banded_cols: false,
396            first_col: false,
397            last_col: false,
398            filter_button: true,
399            filter: vec![],
400            table_type: None,
401            connection_id: None,
402        });
403        let q = crate::formula::quote_sheet(&sheet_name);
404        let col_ref = |c: u32| format!("{q}!${0}$2:${0}${end_row}", col_letters(c));
405        let series = (1..width)
406            .map(|c| Series {
407                name: Some(format!("{q}!${}$1", col_letters(c))),
408                categories: Some(col_ref(0)),
409                values: col_ref(c),
410                bubble_sizes: None,
411                color: None,
412                secondary: false,
413                kind: (opts.column_chart && c >= 3).then_some(ChartKind::Line),
414                quartiles: Default::default(),
415                smooth: false,
416                trendlines: Vec::new(),
417            })
418            .collect();
419        sheet.charts.push(Chart {
420            id: chart_id,
421            kind: if opts.column_chart { ChartKind::ColumnClustered } else { ChartKind::Line },
422            anchor: Anchor { cell: CellRef::new(1, chart_col), dx: 0.0, dy: 0.0, width: 480.0, height: 288.0, mode: Default::default() },
423            title: None,
424            series,
425            legend: LegendPos::Bottom,
426            data_labels: false,
427            data_table: None,
428            gridlines: true,
429            style: 1,
430            hole_size: 75,
431            first_slice_angle: 0,
432            x_title: None,
433            y_title: None,
434            source: None,
435            by_rows: false,
436            pivot: None,
437        });
438        cx.wb.sheets.insert(at, Arc::new(sheet));
439        for nm in cx.wb.names.iter_mut() {
440            if let Some(sc) = nm.scope.as_mut()
441                && *sc >= at
442            {
443                *sc += 1;
444            }
445        }
446        cx.wb.set_active_sheet(at);
447        *cx.sel = crate::engine::Selection::at(CellRef::new(0, 0));
448        cx.structural = true;
449        Ok(at)
450    })?;
451    if let Ok(d) = s.doc_mut() {
452        d.sheet_selections.insert(index.min(d.sheet_selections.len()), crate::engine::Selection::default());
453    }
454    Ok(json!({"sheet": sheet_name, "table": table_name, "chart": chart_id, "history": n, "periods": periods, "fitted": fit}))
455}
456
457/// Forecast rows needed to reach `end` (rounded up to whole steps).
458fn steps_to(timeline: &Timeline, end: f64, cap: u32) -> Result<u32> {
459    let steps = timeline.steps_after_last(end).ok_or_else(|| bad(CMD, "`end` is not on the timeline"))?;
460    if steps < 1e-9 {
461        return Err(EngineError::Other("The forecast end must be after the last point of the timeline.".into()));
462    }
463    let steps = steps.ceil();
464    if steps > f64::from(cap) {
465        return Err(bad(CMD, format!("at most {cap} forecast steps for this much history")));
466    }
467    Ok(steps as u32)
468}
469
470#[cfg(test)]
471mod tests {
472    use crate::base::{CellRef, Value};
473    use serde_json::json;
474
475    use crate::engine::Session;
476
477    fn session_with_history() -> Session {
478        let mut s = Session::new();
479        s.new_workbook();
480        let mut rows = vec![json!(["Month", "Sales"])];
481        // y = 10 + 2t with a four-step season.
482        let season = [3.0, -1.0, 4.0, -6.0];
483        for t in 1..=16 {
484            rows.push(json!([t, 10.0 + 2.0 * t as f64 + season[(t - 1) % 4]]));
485        }
486        s.execute("range.setValues", json!({"range": "A1", "values": rows})).unwrap();
487        s
488    }
489
490    fn value(s: &Session, sheet: usize, a1: &str) -> Value {
491        s.doc().unwrap().wb.sheet(sheet).unwrap().value(CellRef::parse(a1).unwrap())
492    }
493
494    fn formula(s: &Session, sheet: usize, a1: &str) -> String {
495        let wb = &s.doc().unwrap().wb;
496        wb.sheet(sheet).unwrap().cell(CellRef::parse(a1).unwrap()).and_then(|c| c.formula.as_ref()).map(|f| f.text.clone()).unwrap_or_default()
497    }
498
499    #[test]
500    fn forecast_sheet_builds_table_formulas_and_chart() {
501        let mut s = session_with_history();
502        s.execute("selection.set", json!({"range": "A1:B17"})).unwrap();
503        let r = s.execute("data.forecastSheet", json!({"periods": 4, "statistics": true})).unwrap();
504        assert_eq!(r["history"], 16);
505        assert_eq!(r["periods"], 4);
506        let wb = &s.doc().unwrap().wb;
507        // Inserted before the data sheet and activated.
508        assert_eq!(wb.active_sheet(), 0);
509        let sh = wb.sheet(0).unwrap();
510        assert_eq!(sh.name, r["sheet"].as_str().unwrap());
511        assert_eq!(value(&s, 0, "C1"), Value::text("Forecast(Sales)"));
512        assert_eq!(value(&s, 0, "D1"), Value::text("Lower Confidence Bound(Sales)"));
513        assert_eq!(value(&s, 0, "E1"), Value::text("Upper Confidence Bound(Sales)"));
514        assert_eq!(formula(&s, 0, "C17"), "B17");
515        assert_eq!(formula(&s, 0, "C18"), "FORECAST.ETS(A18,$B$2:$B$17,$A$2:$A$17,1,1)");
516        assert_eq!(formula(&s, 0, "D18"), "C18-FORECAST.ETS.CONFINT(A18,$B$2:$B$17,$A$2:$A$17,0.95,1,1)");
517        assert_eq!(formula(&s, 0, "H2"), "FORECAST.ETS.STAT($B$2:$B$17,$A$2:$A$17,1,1,1)");
518        // Future timeline and the exact continuation of the seasonal series (t = 17 → 10 + 34 + 3).
519        assert_eq!(value(&s, 0, "A18"), Value::Number(17.0));
520        assert_eq!(value(&s, 0, "A21"), Value::Number(20.0));
521        let c18 = value(&s, 0, "C18").as_f64().unwrap();
522        assert!((c18 - 47.0).abs() < 1e-6, "{c18}");
523        let (lo, hi) = (value(&s, 0, "D19").as_f64().unwrap(), value(&s, 0, "E19").as_f64().unwrap());
524        assert!(lo <= hi);
525        assert_eq!(sh.tables.first().map(|t| t.range.a1()), Some("A1:E21".into()));
526        let chart = sh.charts.first().unwrap();
527        assert_eq!(chart.series.len(), 4);
528        assert_eq!(chart.series[1].values, format!("{}!$C$2:$C$21", r["sheet"].as_str().unwrap()));
529        // One undo step removes the sheet again.
530        let sheets = s.doc().unwrap().wb.sheets.len();
531        s.execute("edit.undo", json!({})).unwrap();
532        assert_eq!(s.doc().unwrap().wb.sheets.len(), sheets - 1);
533    }
534
535    #[test]
536    fn history_and_periods_are_capped() {
537        use super::{MAX_HISTORY, MAX_PERIODS, MAX_WORK, default_periods, max_periods};
538        // The default (a third of the history) never exceeds the caps.
539        assert_eq!(default_periods(16), 6);
540        assert_eq!(default_periods(1_200), 400);
541        assert_eq!(default_periods(3_000), 166);
542        assert_eq!(default_periods(100_000), 5);
543        assert_eq!(max_periods(10), MAX_PERIODS);
544        assert_eq!(max_periods(0), MAX_PERIODS);
545        for h in [1usize, 2, 3, 50, 51, 1_000, 1_300, 3_000, 10_000, 100_000, usize::MAX] {
546            let (d, m) = (default_periods(h), max_periods(h));
547            assert!(d >= 1 && d <= m && m <= MAX_PERIODS, "{h}: {d} {m}");
548            assert!(u64::try_from(h).unwrap_or(u64::MAX).saturating_mul(u64::from(m)) <= MAX_WORK.max(u64::try_from(h).unwrap_or(u64::MAX)), "{h}");
549        }
550        // Explicit periods or an end past the cap are refused before anything is built.
551        let mut s = Session::new();
552        s.new_workbook();
553        let mut rows = vec![json!(["t", "y"])];
554        rows.extend((1..=1_300).map(|t| json!([t, (t % 7) as f64 + t as f64 * 0.5])));
555        s.execute("range.setValues", json!({"range": "A1", "values": rows})).unwrap();
556        let sheets = s.doc().unwrap().wb.sheets.len();
557        let cap = max_periods(1_300); // 384
558        let err = |r: crate::engine::Result<serde_json::Value>| r.err().map(|e| e.to_string()).unwrap_or_default();
559        assert!(err(s.execute("data.forecastSheet", json!({"range": "A1:B1301", "periods": cap + 1}))).contains("from 1 to 384"));
560        assert!(err(s.execute("data.forecastSheet", json!({"range": "A1:B1301", "end": 1_300 + cap + 1}))).contains("at most 384"));
561        assert!(s.execute("data.forecastSheet", json!({"range": "A1:B1301", "periods": 10_000})).is_err());
562        assert_eq!(s.doc().unwrap().wb.sheets.len(), sheets);
563        // More history rows than MAX_HISTORY are refused.
564        let big = MAX_HISTORY + 2;
565        let r = s.execute("data.forecastSheet", json!({"timeline": format!("A1:A{big}"), "values": format!("B1:B{big}")}));
566        assert!(r.is_err());
567    }
568
569    #[test]
570    fn forecast_sheet_parameters_and_errors() {
571        let mut s = session_with_history();
572        let r = s
573            .execute(
574                "data.forecastSheet",
575                json!({"timeline": "A2:A17", "values": "B2:B17", "end": "22", "confidenceInterval": false, "seasonality": 4, "fillMissing": "zeros", "aggregate": "sum", "chart": "column"}),
576            )
577            .unwrap();
578        assert_eq!(r["periods"], 6);
579        assert_eq!(value(&s, 0, "B1"), Value::text("Values"));
580        assert_eq!(formula(&s, 0, "C18"), "FORECAST.ETS(A18,$B$2:$B$17,$A$2:$A$17,4,0,7)");
581        assert_eq!(value(&s, 0, "D1"), Value::Empty);
582        let sh = s.doc().unwrap().wb.sheet(0).unwrap();
583        assert_eq!(sh.charts.first().map(|c| c.kind), Some(crate::model::ChartKind::ColumnClustered));
584        // Errors: end before the data, bad seasonality, uneven timeline, one column only.
585        s.execute("sheet.activate", json!({"sheet": "Sheet1"})).unwrap();
586        assert!(s.execute("data.forecastSheet", json!({"range": "Sheet1!A1:B17", "end": 3})).is_err());
587        assert!(s.execute("data.forecastSheet", json!({"range": "Sheet1!A1:B17", "seasonality": 9000})).is_err());
588        assert!(s.execute("data.forecastSheet", json!({"range": "Sheet1!B1:B17"})).is_err());
589        s.execute("cell.set", json!({"cell": "A5", "input": "4.5"})).unwrap();
590        assert!(s.execute("data.forecastSheet", json!({"range": "Sheet1!A1:B17"})).is_err());
591    }
592
593    #[test]
594    fn forecast_start_hindcasts_over_the_actuals() {
595        let mut s = session_with_history(); // t = 1..16 in A2:A17
596        let r = s.execute("data.forecastSheet", json!({"range": "A1:B17", "forecastStart": 13, "periods": 2, "confidenceInterval": false})).unwrap();
597        assert_eq!(r["fitted"], 12);
598        // Fit to t = 1..12 (rows 2..13); the line joins at row 13 and forecasts rows 14..19.
599        assert_eq!(formula(&s, 0, "C13"), "B13");
600        assert_eq!(value(&s, 0, "C13"), value(&s, 0, "B13"));
601        assert_eq!(formula(&s, 0, "C14"), "FORECAST.ETS(A14,$B$2:$B$13,$A$2:$A$13,1,1)");
602        assert_eq!(formula(&s, 0, "C19"), "FORECAST.ETS(A19,$B$2:$B$13,$A$2:$A$13,1,1)");
603        // The exact seasonal series is recovered, and the actual is still shown next to it.
604        let c14 = value(&s, 0, "C14").as_f64().unwrap();
605        assert!((c14 - 39.0).abs() < 1e-6, "{c14}"); // t = 13: 10 + 26 + 3
606        assert!(value(&s, 0, "B14").as_f64().is_some());
607        // Start at the last point or one step later: as without forecastStart.
608        for start in [json!(16), json!(17)] {
609            let r = s.execute("data.forecastSheet", json!({"range": "Sheet1!A1:B17", "forecastStart": start, "periods": 2})).unwrap();
610            assert_eq!(r["fitted"], 16);
611        }
612        assert_eq!(formula(&s, 0, "C18"), "FORECAST.ETS(A18,$B$2:$B$17,$A$2:$A$17,1,1)");
613    }
614
615    #[test]
616    fn forecast_start_invalid_values_are_errors() {
617        let mut s = session_with_history();
618        let sheets = s.doc().unwrap().wb.sheets.len();
619        for bad in [json!(0), json!(2), json!(3.5), json!(18), json!(1000), json!("abc"), json!([1]), json!(1e308)] {
620            assert!(s.execute("data.forecastSheet", json!({"range": "A1:B17", "forecastStart": bad})).is_err(), "{bad}");
621        }
622        assert_eq!(s.doc().unwrap().wb.sheets.len(), sheets);
623        // Blank or null behaves like no start; empty params unchanged.
624        let r = s.execute("data.forecastSheet", json!({"range": "A1:B17", "forecastStart": ""})).unwrap();
625        assert_eq!(r["fitted"], 16);
626    }
627}