diff --git a/ROADMAP.md b/ROADMAP.md index 091c139..5cd6bf6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -134,6 +134,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in |---|---| | 2026-10-11 | Legacy Excel 97-2003 `.xls` (BIFF8) import with formulas, number formats, merges, sizes and names (#121) | | 2026-10-11 | XLSX pass-through: custom XML, external links, connections, query tables, printer settings, form/ActiveX controls and OLE objects are kept on save (#330, #324) | +| 2026-10-11 | Data › Forecast › Forecast Sheet: a new sheet with the history, FORECAST.ETS forecast, confidence bounds, statistics and a chart (#322) | | 2026-10-11 | Startup no longer panics on a system font egui can't parse: fonts are validated and skipped (#226) | | 2026-10-11 | Ctrl+drag on the fill handle swaps copying and extending a series (#264) | | 2026-10-11 | Odd-period bond functions ODDFPRICE, ODDFYIELD, ODDLPRICE, ODDLYIELD: Financial 55 / 55 (#275) | @@ -163,6 +164,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Forecast Sheet (#322); progress log row | | 2026-10-11 | minor | Worksheet functions 505 / 523 (ODD* bond functions); progress log row | | 2026-10-11 | minor | Worksheet functions 503 / 523 (PERCENTOF, EUROCONVERT); progress log row | | 2026-10-11 | minor | Worksheet functions 502 / 523 (BAHTTEXT); progress log row | diff --git a/crates/engine/src/cmd/forecast.rs b/crates/engine/src/cmd/forecast.rs new file mode 100644 index 0000000..f53ab66 --- /dev/null +++ b/crates/engine/src/cmd/forecast.rs @@ -0,0 +1,546 @@ +//! Data › Forecast › Forecast Sheet: a new sheet with the history, a FORECAST.ETS column, the +//! FORECAST.ETS.CONFINT confidence bounds, optional FORECAST.ETS.STAT statistics and a chart. +//! +//! Like Excel, the sheet holds a copy of the timeline and values (sorted by time) and live +//! formulas over that copy, so the forecast follows edits to the copied history. + +use std::sync::Arc; + +use gridcraft_core::{CellRef, RangeRef, Value}; +use gridcraft_functions::Timeline; +use gridcraft_model::{Anchor, Cell, Chart, ChartKind, Formula, LegendPos, Series, Sheet, StyleId, Table, TableColumn, TotalsFn}; +use serde_json::{Value as Json, json}; + +use super::*; +use crate::selection::current_region; + +const CMD: &str = "data.forecastSheet"; +/// Most history rows a forecast sheet copies. +const MAX_HISTORY: u64 = 10_000; +/// Most forecast rows. +const MAX_PERIODS: u32 = 10_000; +/// Most history rows × forecast rows. Every forecast row holds three ETS formulas that each refit +/// the model over the whole history (about 3 µs per history point in a release build), so this +/// bounds a sheet's recalculation to a few seconds: 10,000 rows of history get 50 forecast rows, +/// 50 rows of history the full 10,000. +const MAX_WORK: u64 = 500_000; + +/// Forecast rows by default: a third of the history, within [`max_periods`]. +fn default_periods(history: usize) -> u32 { + u32::try_from(history.div_ceil(3)).unwrap_or(u32::MAX).clamp(1, max_periods(history)) +} + +/// Most forecast rows for `history` rows of history. +fn max_periods(history: usize) -> u32 { + let h = u64::try_from(history).unwrap_or(u64::MAX).max(1); + u32::try_from(MAX_WORK / h).unwrap_or(u32::MAX).clamp(1, MAX_PERIODS) +} + +pub fn specs() -> Vec { + vec![cmd!( + "data.forecastSheet", + "Forecast Sheet", + ["Data", "Forecast"], + None, + "{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), 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.", + has_doc, + forecast_sheet + )] +} + +/// The cells of a one-row or one-column range, at most `MAX_HISTORY + 1`. +fn line_cells(r: RangeRef) -> Result> { + if r.count() > MAX_HISTORY + 1 { + return Err(bad(CMD, format!("at most {MAX_HISTORY} rows of history"))); + } + if r.width() == 1 { + Ok((r.start.row..=r.end.row).map(|row| CellRef::new(row, r.start.col)).collect()) + } else if r.height() == 1 { + Ok((r.start.col..=r.end.col).map(|col| CellRef::new(r.start.row, col)).collect()) + } else { + Err(bad(CMD, "the timeline and the values are each one row or one column")) + } +} + +/// A range parameter on any sheet (whole columns trimmed to the used range). +fn range_on(s: &Session, t: &str) -> Result<(usize, RangeRef)> { + let d = s.doc()?; + let (sh, body) = split_sheet(t); + let si = match sh { + Some(n) => d.wb.sheet_index(&n).ok_or_else(|| bad(CMD, format!("no sheet named `{n}`")))?, + None => d.wb.active_sheet, + }; + let r = RangeRef::parse(body.trim()).ok_or_else(|| bad(CMD, format!("not a range: `{t}`")))?; + let r = if r.is_full_cols() || r.is_full_rows() { + match d.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), + } + } else { + r + }; + Ok((si, r)) +} + +/// (sheet, timeline cells, value cells). +fn source(s: &Session, p: &Json) -> Result<(usize, Vec, Vec)> { + let text = |k: &str| str_param(p, k).map(str::trim).filter(|t| !t.is_empty()); + match (text("timeline"), text("values")) { + (Some(t), Some(v)) => { + let (ts, tr) = range_on(s, t)?; + let (vs, vr) = range_on(s, v)?; + if ts != vs { + return Err(bad(CMD, "the timeline and the values must be on one sheet")); + } + Ok((ts, line_cells(tr)?, line_cells(vr)?)) + } + (None, None) => { + let d = s.doc()?; + let (si, r) = match text("range") { + Some(t) => range_on(s, t)?, + None => { + let sh = d.wb.active().ok_or(EngineError::NoDocument)?; + let sel = d.selection.current(); + (d.wb.active_sheet, if sel.is_single() { current_region(sh, d.selection.active) } else { sel }) + } + }; + if r.width() < 2 { + return Err(EngineError::Other("Select a timeline column and a values column for the forecast.".into())); + } + let col = |c: u32| RangeRef::new(CellRef::new(r.start.row, c), CellRef::new(r.end.row, c)); + Ok((si, line_cells(col(r.start.col))?, line_cells(col(r.start.col + 1))?)) + } + _ => Err(bad(CMD, "give both `timeline` and `values`, or neither")), + } +} + +/// A number parameter given as a number or as text. +fn number(p: &Json, key: &str) -> Result> { + match p.get(key) { + None | Some(Json::Null) => Ok(None), + Some(Json::Number(n)) => Ok(n.as_f64()), + Some(Json::String(t)) if t.trim().is_empty() => Ok(None), + Some(Json::String(t)) => { + t.trim().trim_end_matches('%').trim().parse::().map(Some).map_err(|_| bad(CMD, format!("`{key}` is not a number"))) + } + Some(_) => Err(bad(CMD, format!("`{key}` is not a number"))), + } +} + +struct Options { + seasonality: u32, + fill: u8, + aggregate: Option, + confidence: Option, + statistics: bool, + column_chart: bool, +} + +fn options(p: &Json) -> Result { + let seasonality = match p.get("seasonality") { + Some(Json::String(t)) if t.trim().is_empty() || t.trim().eq_ignore_ascii_case("auto") => 1, + None | Some(Json::Null) => 1, + _ => match number(p, "seasonality")? { + Some(n) if n == n.trunc() && (0.0..=8760.0).contains(&n) => n as u32, + _ => return Err(bad(CMD, "`seasonality` is \"auto\", 0 (none) or a season length from 2 to 8,760")), + }, + }; + let fill = match str_param(p, "fillMissing").map(|t| t.trim().to_ascii_lowercase()) { + None => 1, + Some(t) if t.is_empty() || t == "interpolate" || t == "interpolation" => 1, + Some(t) if t == "zeros" || t == "zero" => 0, + Some(t) => return Err(bad(CMD, format!("`fillMissing` is interpolate or zeros, not `{t}`"))), + }; + let aggregate = match str_param(p, "aggregate").map(|t| t.trim().to_ascii_lowercase()) { + None => None, + Some(t) => match t.as_str() { + "" | "average" => None, + "count" => Some(2), + "counta" => Some(3), + "max" => Some(4), + "median" => Some(5), + "min" => Some(6), + "sum" => Some(7), + _ => return Err(bad(CMD, format!("unknown `aggregate`: `{t}`"))), + }, + }; + let confidence = if bool_param(p, "confidenceInterval").unwrap_or(true) { + let c = number(p, "confidence")?.unwrap_or(0.95); + let c = if c >= 1.0 { c / 100.0 } else { c }; + if !(c > 0.0 && c < 1.0) { + return Err(bad(CMD, "`confidence` is between 0 and 1 (or 0 and 100%)")); + } + Some(c) + } else { + None + }; + let column_chart = match str_param(p, "chart").map(|t| t.trim().to_ascii_lowercase()) { + None => false, + Some(t) if t.is_empty() || t == "line" => false, + Some(t) if t == "column" => true, + Some(t) => return Err(bad(CMD, format!("`chart` is line or column, not `{t}`"))), + }; + Ok(Options { seasonality, fill, aggregate, confidence, statistics: bool_param(p, "statistics").unwrap_or(false), column_chart }) +} + +fn col_letters(c: u32) -> String { + gridcraft_core::col_to_letters(c) +} + +fn forecast_sheet(s: &mut Session, p: &Json) -> Result { + let opts = options(p)?; + let (si, tcells, vcells) = source(s, p)?; + if tcells.len() != vcells.len() { + return Err(bad(CMD, "the timeline and the values need the same number of cells")); + } + let d = s.doc()?; + if d.wb.protected_structure { + return Err(EngineError::Other("The workbook is protected and cannot be changed.".into())); + } + let sys = d.wb.date_system; + let sh = d.wb.sheet(si).ok_or(EngineError::NoDocument)?; + // A text first cell is the header. + let header = tcells.first().is_some_and(|c| sh.value(*c).is_text()) || vcells.first().is_some_and(|c| sh.value(*c).is_text()); + let name_of = |c: Option<&CellRef>, fallback: &str| match c.map(|c| sh.value(*c)) { + Some(v) if header && !v.is_empty() => v.display(), + _ => fallback.to_string(), + }; + let (tname, vname) = (name_of(tcells.first(), "Timeline"), name_of(vcells.first(), "Values")); + let skip = usize::from(header); + let mut rows: Vec<(f64, Value)> = Vec::new(); + for (t, v) in tcells.iter().zip(&vcells).skip(skip) { + let (tv, vv) = (sh.value(*t), sh.value(*v)); + match tv { + Value::Number(x) => rows.push((x, if matches!(vv, Value::Number(_)) { vv } else { Value::Empty })), + Value::Empty if vv.is_empty() => {} + _ => return Err(EngineError::Other(format!("The timeline must hold numbers or dates ({} isn't one).", t.a1()))), + } + } + rows.sort_by(|a, b| a.0.total_cmp(&b.0)); + let times: Vec = rows.iter().map(|r| r.0).collect(); + let timeline = + Timeline::detect(×, sys).ok_or_else(|| EngineError::Other("The timeline needs at least two points a constant step apart.".into()))?; + if !rows.iter().any(|r| matches!(r.1, Value::Number(_))) { + return Err(EngineError::Other("There are no values to forecast.".into())); + } + let end = match p.get("end") { + Some(Json::Number(n)) => n.as_f64(), + Some(Json::String(t)) if !t.trim().is_empty() => match gridcraft_core::parse::parse_input(t.trim(), sys).value { + Value::Number(end) => Some(end), + _ => return Err(bad(CMD, format!("`end` is not a number or a date: `{t}`"))), + }, + _ => None, + }; + let cap = max_periods(rows.len()); + let periods = match end { + Some(end) => steps_to(&timeline, end, cap)?, + None => match number(p, "periods")? { + Some(n) if n >= 1.0 && n.ceil() <= f64::from(cap) => n.ceil() as u32, + Some(_) => return Err(bad(CMD, format!("`periods` is from 1 to {cap} for {} rows of history", rows.len()))), + None => default_periods(rows.len()), + }, + }; + let future: Vec = + (1..=periods).map(|k| timeline.after_last(k)).collect::>().ok_or_else(|| bad(CMD, "the forecast runs past the last date"))?; + let fmt_of = |c: Option<&CellRef>| c.map(|c| d.wb.styles.get(sh.style_id(*c)).num_fmt.clone()).unwrap_or_default(); + let (tfmt, vfmt) = (fmt_of(tcells.get(skip)), fmt_of(vcells.get(skip))); + let sheet_name = d.wb.next_sheet_name(); + let table_name = (1..).map(|n| format!("Table{n}")).find(|n| d.wb.table(n).is_none()).unwrap_or_else(|| "Table".into()); + let chart_id = d.wb.next_object_id(); + let table_id = chart_id.saturating_add(1); + + // Columns: A timeline, B values, C forecast, D/E bounds. + let n = rows.len() as u32; + let last = n + 1; // 1-based row of the last history point + let end_row = last + periods; // 1-based row of the last forecast + let hist = |col: u32| format!("${0}$2:${0}${last}", col_letters(col)); + let mut tail = format!("{},{}", opts.seasonality, opts.fill); + if let Some(a) = opts.aggregate { + tail.push_str(&format!(",{a}")); + } + let mut headers = vec![tname, vname.clone(), format!("Forecast({vname})")]; + if opts.confidence.is_some() { + headers.push(format!("Lower Confidence Bound({vname})")); + headers.push(format!("Upper Confidence Bound({vname})")); + } + // Table column names are unique. + for i in 1..headers.len() { + let base = headers.get(i).cloned().unwrap_or_default(); + let mut name = base.clone(); + let mut k = 2; + while headers.iter().take(i).any(|h| h.eq_ignore_ascii_case(&name)) { + name = format!("{base}{k}"); + k += 1; + } + if let Some(h) = headers.get_mut(i) { + *h = name; + } + } + let width = headers.len() as u32; + let stats_col = width + 1; + let chart_col = if opts.statistics { stats_col + 3 } else { width + 1 }; + + let at = s.doc()?.wb.active_sheet; + let index = edit(s, |cx| { + let at = at.min(cx.wb.sheets.len()); + let bold = cx.wb.styles.derive(StyleId::DEFAULT, |st| st.font.bold = true); + let tstyle = cx.wb.styles.derive(StyleId::DEFAULT, |st| st.num_fmt = tfmt.clone()); + let vstyle = cx.wb.styles.derive(StyleId::DEFAULT, |st| st.num_fmt = vfmt.clone()); + let mut sheet = Sheet::new(sheet_name.clone()); + let put = |sheet: &mut Sheet, row: u32, col: u32, v: Value, style: StyleId| { + sheet.set_cell(CellRef::new(row, col), Cell { value: v, formula: None, style }); + }; + let formula = |sheet: &mut Sheet, row: u32, col: u32, text: String, style: StyleId| { + let mut cell = Cell::formula(Formula::new(&text)); + cell.style = style; + sheet.set_cell(CellRef::new(row, col), cell); + }; + for (c, h) in headers.iter().enumerate() { + put(&mut sheet, 0, c as u32, Value::text(h.as_str()), StyleId::DEFAULT); + } + for (i, (t, v)) in rows.iter().enumerate() { + let r = i as u32 + 1; + put(&mut sheet, r, 0, Value::Number(*t), tstyle); + if !v.is_empty() { + put(&mut sheet, r, 1, v.clone(), vstyle); + } + } + // The last history point starts the forecast lines, so they join the values. + formula(&mut sheet, n, 2, format!("=B{last}"), vstyle); + if opts.confidence.is_some() { + formula(&mut sheet, n, 3, format!("=C{last}"), vstyle); + formula(&mut sheet, n, 4, format!("=C{last}"), vstyle); + } + let (tl, vl) = (hist(0), hist(1)); + for (k, t) in future.iter().enumerate() { + let r = last + k as u32 + 1; // 1-based + put(&mut sheet, r - 1, 0, Value::Number(*t), tstyle); + formula(&mut sheet, r - 1, 2, format!("=FORECAST.ETS(A{r},{vl},{tl},{tail})"), vstyle); + if let Some(c) = opts.confidence { + let ci = format!("FORECAST.ETS.CONFINT(A{r},{vl},{tl},{c},{tail})"); + formula(&mut sheet, r - 1, 3, format!("=C{r}-{ci}"), vstyle); + formula(&mut sheet, r - 1, 4, format!("=C{r}+{ci}"), vstyle); + } + } + if opts.statistics { + put(&mut sheet, 0, stats_col, Value::text("Statistic"), bold); + put(&mut sheet, 0, stats_col + 1, Value::text("Value"), bold); + for (k, label) in ["Alpha", "Beta", "Gamma", "MASE", "SMAPE", "MAE", "RMSE"].iter().enumerate() { + let r = k as u32 + 1; + put(&mut sheet, r, stats_col, Value::text(*label), StyleId::DEFAULT); + formula(&mut sheet, r, stats_col + 1, format!("=FORECAST.ETS.STAT({vl},{tl},{},{tail})", k + 1), StyleId::DEFAULT); + } + } + for c in 0..width { + sheet.cols.insert( + c, + gridcraft_model::LineInfo { + size: Some(match c { + 0 | 1 => 90.0, + 2 => 130.0, + _ => 230.0, + }), + ..Default::default() + }, + ); + } + let range = RangeRef::new(CellRef::new(0, 0), CellRef::new(end_row - 1, width - 1)); + sheet.tables.push(Table { + id: table_id, + name: table_name.clone(), + range, + header_row: true, + totals_row: false, + columns: headers.iter().map(|h| TableColumn { name: h.clone(), totals: TotalsFn::None, totals_label: None, formula: None }).collect(), + style: "TableStyleMedium2".into(), + banded_rows: true, + banded_cols: false, + first_col: false, + last_col: false, + filter_button: true, + }); + let q = gridcraft_formula::quote_sheet(&sheet_name); + let col_ref = |c: u32| format!("{q}!${0}$2:${0}${end_row}", col_letters(c)); + let series = (1..width) + .map(|c| Series { + name: Some(format!("{q}!${}$1", col_letters(c))), + categories: Some(col_ref(0)), + values: col_ref(c), + bubble_sizes: None, + color: None, + secondary: false, + kind: (opts.column_chart && c >= 3).then_some(ChartKind::Line), + smooth: false, + }) + .collect(); + sheet.charts.push(Chart { + id: chart_id, + kind: if opts.column_chart { ChartKind::ColumnClustered } else { ChartKind::Line }, + anchor: Anchor { cell: CellRef::new(1, chart_col), dx: 0.0, dy: 0.0, width: 480.0, height: 288.0, mode: Default::default() }, + title: None, + series, + legend: LegendPos::Bottom, + data_labels: false, + gridlines: true, + style: 1, + x_title: None, + y_title: None, + source: None, + by_rows: false, + pivot: None, + }); + cx.wb.sheets.insert(at, Arc::new(sheet)); + for nm in cx.wb.names.iter_mut() { + if let Some(sc) = nm.scope.as_mut() + && *sc >= at + { + *sc += 1; + } + } + cx.wb.active_sheet = at; + *cx.sel = crate::Selection::at(CellRef::new(0, 0)); + cx.structural = true; + Ok(at) + })?; + if let Ok(d) = s.doc_mut() { + d.sheet_selections.insert(index.min(d.sheet_selections.len()), crate::Selection::default()); + } + Ok(json!({"sheet": sheet_name, "table": table_name, "chart": chart_id, "history": n, "periods": periods})) +} + +/// Forecast rows needed to reach `end` (rounded up to whole steps). +fn steps_to(timeline: &Timeline, end: f64, cap: u32) -> Result { + let steps = timeline.steps_after_last(end).ok_or_else(|| bad(CMD, "`end` is not on the timeline"))?; + if steps < 1e-9 { + return Err(EngineError::Other("The forecast end must be after the last point of the timeline.".into())); + } + let steps = steps.ceil(); + if steps > f64::from(cap) { + return Err(bad(CMD, format!("at most {cap} forecast steps for this much history"))); + } + Ok(steps as u32) +} + +#[cfg(test)] +mod tests { + use gridcraft_core::{CellRef, Value}; + use serde_json::json; + + use crate::Session; + + fn session_with_history() -> Session { + let mut s = Session::new(); + s.new_workbook(); + let mut rows = vec![json!(["Month", "Sales"])]; + // y = 10 + 2t with a four-step season. + let season = [3.0, -1.0, 4.0, -6.0]; + for t in 1..=16 { + rows.push(json!([t, 10.0 + 2.0 * t as f64 + season[(t - 1) % 4]])); + } + s.execute("range.setValues", json!({"range": "A1", "values": rows})).unwrap(); + s + } + + fn value(s: &Session, sheet: usize, a1: &str) -> Value { + s.doc().unwrap().wb.sheet(sheet).unwrap().value(CellRef::parse(a1).unwrap()) + } + + fn formula(s: &Session, sheet: usize, a1: &str) -> String { + let wb = &s.doc().unwrap().wb; + wb.sheet(sheet).unwrap().cell(CellRef::parse(a1).unwrap()).and_then(|c| c.formula.as_ref()).map(|f| f.text.clone()).unwrap_or_default() + } + + #[test] + fn forecast_sheet_builds_table_formulas_and_chart() { + let mut s = session_with_history(); + s.execute("selection.set", json!({"range": "A1:B17"})).unwrap(); + let r = s.execute("data.forecastSheet", json!({"periods": 4, "statistics": true})).unwrap(); + assert_eq!(r["history"], 16); + assert_eq!(r["periods"], 4); + let wb = &s.doc().unwrap().wb; + // Inserted before the data sheet and activated. + assert_eq!(wb.active_sheet, 0); + let sh = wb.sheet(0).unwrap(); + assert_eq!(sh.name, r["sheet"].as_str().unwrap()); + assert_eq!(value(&s, 0, "C1"), Value::text("Forecast(Sales)")); + assert_eq!(value(&s, 0, "D1"), Value::text("Lower Confidence Bound(Sales)")); + assert_eq!(value(&s, 0, "E1"), Value::text("Upper Confidence Bound(Sales)")); + assert_eq!(formula(&s, 0, "C17"), "B17"); + assert_eq!(formula(&s, 0, "C18"), "FORECAST.ETS(A18,$B$2:$B$17,$A$2:$A$17,1,1)"); + assert_eq!(formula(&s, 0, "D18"), "C18-FORECAST.ETS.CONFINT(A18,$B$2:$B$17,$A$2:$A$17,0.95,1,1)"); + assert_eq!(formula(&s, 0, "H2"), "FORECAST.ETS.STAT($B$2:$B$17,$A$2:$A$17,1,1,1)"); + // Future timeline and the exact continuation of the seasonal series (t = 17 → 10 + 34 + 3). + assert_eq!(value(&s, 0, "A18"), Value::Number(17.0)); + assert_eq!(value(&s, 0, "A21"), Value::Number(20.0)); + let c18 = value(&s, 0, "C18").as_f64().unwrap(); + assert!((c18 - 47.0).abs() < 1e-6, "{c18}"); + let (lo, hi) = (value(&s, 0, "D19").as_f64().unwrap(), value(&s, 0, "E19").as_f64().unwrap()); + assert!(lo <= hi); + assert_eq!(sh.tables.first().map(|t| t.range.a1()), Some("A1:E21".into())); + let chart = sh.charts.first().unwrap(); + assert_eq!(chart.series.len(), 4); + assert_eq!(chart.series[1].values, format!("{}!$C$2:$C$21", r["sheet"].as_str().unwrap())); + // One undo step removes the sheet again. + let sheets = s.doc().unwrap().wb.sheets.len(); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.doc().unwrap().wb.sheets.len(), sheets - 1); + } + + #[test] + fn history_and_periods_are_capped() { + use super::{MAX_HISTORY, MAX_PERIODS, MAX_WORK, default_periods, max_periods}; + // The default (a third of the history) never exceeds the caps. + assert_eq!(default_periods(16), 6); + assert_eq!(default_periods(1_200), 400); + assert_eq!(default_periods(3_000), 166); + assert_eq!(default_periods(100_000), 5); + assert_eq!(max_periods(10), MAX_PERIODS); + assert_eq!(max_periods(0), MAX_PERIODS); + for h in [1usize, 2, 3, 50, 51, 1_000, 1_300, 3_000, 10_000, 100_000, usize::MAX] { + let (d, m) = (default_periods(h), max_periods(h)); + assert!(d >= 1 && d <= m && m <= MAX_PERIODS, "{h}: {d} {m}"); + 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}"); + } + // Explicit periods or an end past the cap are refused before anything is built. + let mut s = Session::new(); + s.new_workbook(); + let mut rows = vec![json!(["t", "y"])]; + rows.extend((1..=1_300).map(|t| json!([t, (t % 7) as f64 + t as f64 * 0.5]))); + s.execute("range.setValues", json!({"range": "A1", "values": rows})).unwrap(); + let sheets = s.doc().unwrap().wb.sheets.len(); + let cap = max_periods(1_300); // 384 + let err = |r: crate::Result| r.err().map(|e| e.to_string()).unwrap_or_default(); + assert!(err(s.execute("data.forecastSheet", json!({"range": "A1:B1301", "periods": cap + 1}))).contains("from 1 to 384")); + assert!(err(s.execute("data.forecastSheet", json!({"range": "A1:B1301", "end": 1_300 + cap + 1}))).contains("at most 384")); + assert!(s.execute("data.forecastSheet", json!({"range": "A1:B1301", "periods": 10_000})).is_err()); + assert_eq!(s.doc().unwrap().wb.sheets.len(), sheets); + // More history rows than MAX_HISTORY are refused. + let big = MAX_HISTORY + 2; + let r = s.execute("data.forecastSheet", json!({"timeline": format!("A1:A{big}"), "values": format!("B1:B{big}")})); + assert!(r.is_err()); + } + + #[test] + fn forecast_sheet_parameters_and_errors() { + let mut s = session_with_history(); + let r = s + .execute( + "data.forecastSheet", + json!({"timeline": "A2:A17", "values": "B2:B17", "end": "22", "confidenceInterval": false, "seasonality": 4, "fillMissing": "zeros", "aggregate": "sum", "chart": "column"}), + ) + .unwrap(); + assert_eq!(r["periods"], 6); + assert_eq!(value(&s, 0, "B1"), Value::text("Values")); + assert_eq!(formula(&s, 0, "C18"), "FORECAST.ETS(A18,$B$2:$B$17,$A$2:$A$17,4,0,7)"); + assert_eq!(value(&s, 0, "D1"), Value::Empty); + let sh = s.doc().unwrap().wb.sheet(0).unwrap(); + assert_eq!(sh.charts.first().map(|c| c.kind), Some(gridcraft_model::ChartKind::ColumnClustered)); + // Errors: end before the data, bad seasonality, uneven timeline, one column only. + s.execute("sheet.activate", json!({"sheet": "Sheet1"})).unwrap(); + assert!(s.execute("data.forecastSheet", json!({"range": "Sheet1!A1:B17", "end": 3})).is_err()); + assert!(s.execute("data.forecastSheet", json!({"range": "Sheet1!A1:B17", "seasonality": 9000})).is_err()); + assert!(s.execute("data.forecastSheet", json!({"range": "Sheet1!B1:B17"})).is_err()); + s.execute("cell.set", json!({"cell": "A5", "input": "4.5"})).unwrap(); + assert!(s.execute("data.forecastSheet", json!({"range": "Sheet1!A1:B17"})).is_err()); + } +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 62baad0..2109555 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -7,6 +7,7 @@ pub mod draw; pub mod edit; pub mod extra; pub mod file; +pub mod forecast; pub mod format; pub mod formulas; pub mod insert; @@ -138,6 +139,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(solver::specs()); v.extend(pivot::specs()); v.extend(whatif::specs()); + v.extend(forecast::specs()); v.extend(analysis::specs()); v.extend(print::specs()); v diff --git a/crates/functions/src/ets.rs b/crates/functions/src/ets.rs index 4649f41..617b171 100644 --- a/crates/functions/src/ets.rs +++ b/crates/functions/src/ets.rs @@ -25,7 +25,7 @@ //! Prediction intervals use the model's h-step forecast variance (the book's Table 6.1, class 1): //! `σ²·(1 + Σ_{j=1}^{h−1} (α + αβ·j + γ·[j mod m = 0])²)`, σ² = SSE / n, with the normal quantile. -use gridcraft_core::date::{datetime_from_serial, days_in_month_in}; +use gridcraft_core::date::{datetime_from_serial, days_in_month_in, serial_from_ymd}; use gridcraft_core::{CellError, DateSystem, Value}; use crate::special::norm_inv; @@ -391,6 +391,62 @@ fn interpolate(slots: &[Option]) -> Vec { out } +/// A timeline's step, for continuing it past its last point (the Forecast Sheet's future rows). +#[derive(Clone, Copy, Debug)] +pub struct Timeline { + axis: Axis, + /// Position of the last time stamp. + last: i64, +} + +impl Timeline { + /// The step of a set of time stamps (any order, duplicates allowed): a constant numeric step + /// (missing points allowed) or, for whole-day dates on one day of the month, calendar months. + /// `None` without at least two distinct, finite time stamps or without a constant step. + pub fn detect(times: &[f64], sys: DateSystem) -> Option { + if times.iter().any(|t| !t.is_finite()) { + return None; + } + let mut sorted = times.to_vec(); + sorted.sort_by(|a, b| a.total_cmp(b)); + sorted.dedup(); + let axis = detect_axis(&sorted, sys).ok()?; + let last = axis.position(*sorted.last()?)?.round(); + // Like the functions, at most 30% of the points may be missing. + if !(0.0..MAX_POINTS as f64).contains(&last) || (last + 1.0 - sorted.len() as f64) > MAX_MISSING * (last + 1.0) { + return None; + } + Some(Timeline { axis, last: last as i64 }) + } + + /// Steps from the last time stamp to `t` (fractional between steps, negative before it). + pub fn steps_after_last(&self, t: f64) -> Option { + let p = self.axis.position(t)? - self.last as f64; + p.is_finite().then_some(p) + } + + /// The time stamp `k` steps after the last one. + pub fn after_last(&self, k: u32) -> Option { + let pos = self.last.checked_add(i64::from(k))?; + match self.axis { + Axis::Linear { t0, step } => { + let t = t0 + pos as f64 * step; + t.is_finite().then_some(t) + } + Axis::Months { sys, month0, step, day } => { + let month = month0.checked_add(pos.checked_mul(step)?)?; + let (y, m) = (month.div_euclid(12), month.rem_euclid(12) + 1); + let dim = i64::from(days_in_month_in(sys, i32::try_from(y).ok()?, u32::try_from(m).ok()?)); + let d = day.map_or(dim, |d| i64::from(d).min(dim)); + if y < 1900 { + return None; + } + serial_from_ymd(sys, y, m, d) + } + } + } +} + // --------------------------------------------------------------------------------------------- // Seasonality detection // --------------------------------------------------------------------------------------------- @@ -1002,4 +1058,19 @@ mod tests { is_err(ets(vec![n(1e15), range(&[1.0, 2.0, 3.0]), range(&[0.0, 1.0, 1e12])]), CellError::Num); is_err(ets(vec![n(1e308), range(&ys), range(&ts)]), CellError::Num); } + + #[test] + fn timelines_continue_past_their_last_point() { + let tl = Timeline::detect(&[3.0, 1.0, 2.0, 2.0, 5.0], DateSystem::D1900).expect("numeric step"); + assert_eq!(tl.after_last(1), Some(6.0)); + assert_eq!(tl.steps_after_last(8.5), Some(3.5)); + // Month ends: 2024-01-31 (45322), 2024-02-29 (45351), 2024-03-31 (45382). + let tl = Timeline::detect(&[45322.0, 45351.0, 45382.0], DateSystem::D1900).expect("months"); + assert_eq!(tl.after_last(1), Some(45412.0)); // 2024-04-30 + assert_eq!(tl.steps_after_last(45412.0), Some(1.0)); + assert!(Timeline::detect(&[1.0, 2.0, 4.5], DateSystem::D1900).is_none()); + assert!(Timeline::detect(&[1.0, 2.0, 9.0], DateSystem::D1900).is_none(), "too many missing points"); + assert!(Timeline::detect(&[1.0], DateSystem::D1900).is_none()); + assert!(Timeline::detect(&[1.0, f64::NAN], DateSystem::D1900).is_none()); + } } diff --git a/crates/functions/src/lib.rs b/crates/functions/src/lib.rs index a54691d..1e4415e 100644 --- a/crates/functions/src/lib.rs +++ b/crates/functions/src/lib.rs @@ -76,6 +76,7 @@ pub(crate) mod stat; pub(crate) mod text; pub(crate) mod util; +pub use ets::Timeline; pub use stat::{aggregate_values, aggregate_values_k}; /// An evaluated argument. Ranges arrive as `Value::Array` (blank cells = `Value::Empty`) with diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 52f2df5..196d47a 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -373,6 +373,40 @@ impl Dialog { json!({"header": true}), ), "goalSeek" => Dialog::custom("goalSeek", "Goal Seek", json!({"set": active, "to": "0", "changing": ""})), + "forecastSheet" => Dialog::form( + "forecastSheet", + "Create Forecast Worksheet", + "data.forecastSheet", + vec![ + Text { key: "timeline", label: "Timeline range (blank: first column of the selection):" }, + Text { key: "values", label: "Values range (blank: second column of the selection):" }, + Text { key: "end", label: "Forecast end (date or number; blank: a third of the history):" }, + Check { key: "confidenceInterval", label: "Confidence interval" }, + Text { key: "confidence", label: "Confidence level (%):" }, + Text { key: "seasonality", label: "Seasonality (auto, 0 for none, or a season length):" }, + Choice { + key: "fillMissing", + label: "Fill missing points using", + options: vec![("interpolate", "Interpolation"), ("zeros", "Zeros")], + }, + Choice { + key: "aggregate", + label: "Aggregate duplicates using", + options: vec![ + ("average", "Average"), + ("count", "Count"), + ("counta", "CountA"), + ("max", "Max"), + ("median", "Median"), + ("min", "Min"), + ("sum", "Sum"), + ], + }, + Check { key: "statistics", label: "Include forecast statistics" }, + Choice { key: "chart", label: "Chart", options: vec![("line", "Line chart"), ("column", "Column chart")] }, + ], + json!({"confidenceInterval": true, "confidence": "95", "seasonality": "auto", "fillMissing": "interpolate", "aggregate": "average", "chart": "line"}), + ), "analysisToolPak" => { // Opened with a known `tool`, it goes straight to that tool's form. let tool = params.get("tool").and_then(Json::as_str).unwrap_or(""); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index e5c2339..9b2831c 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -342,6 +342,7 @@ const JAPANESE: &[(&str, &str)] = &[ ("Data Table…", "データ テーブル…"), ("Scenario Manager…", "シナリオ マネージャー…"), ("Goal Seek…", "ゴール シーク…"), + ("Forecast\nSheet", "予測シート"), ("Spelling", "スペルチェック"), ("Check\nAccessibility", "アクセシビリティ チェック"), ("Threaded Comments", "スレッド化されたコメント"), diff --git a/crates/ui-egui/src/i18n/ko.rs b/crates/ui-egui/src/i18n/ko.rs index 14e4e2d..fb33cbd 100644 --- a/crates/ui-egui/src/i18n/ko.rs +++ b/crates/ui-egui/src/i18n/ko.rs @@ -179,6 +179,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Data Table…" => "데이터 표…", "Scenario Manager…" => "시나리오 관리자…", "Goal Seek…" => "목표값 찾기…", + "Forecast\nSheet" => "예측 시트", "Spelling" => "맞춤법 검사", "Check\nAccessibility" => "접근성\n검사", "Threaded Comments" => "스레드 댓글", diff --git a/crates/ui-egui/src/i18n/pt.rs b/crates/ui-egui/src/i18n/pt.rs index 3882e7d..35f1336 100644 --- a/crates/ui-egui/src/i18n/pt.rs +++ b/crates/ui-egui/src/i18n/pt.rs @@ -180,6 +180,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Data Table…" => "Tabela de Dados…", "Scenario Manager…" => "Gerenciador de Cenários…", "Goal Seek…" => "Buscar Objetivo…", + "Forecast\nSheet" => "Planilha de\nPrevisão", "Spelling" => "Ortografia", "Check\nAccessibility" => "Verificar\nAcessibilidade", "Threaded Comments" => "Comentários Encadeados", diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index c05b97a..e50e5ff 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -179,6 +179,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Data Table…" => "Таблица данных…", "Scenario Manager…" => "Диспетчер сценариев…", "Goal Seek…" => "Подбор параметра…", + "Forecast\nSheet" => "Лист\nпрогноза", "Spelling" => "Орфография", "Check\nAccessibility" => "Проверка\nдоступности", "Threaded Comments" => "Комментарии с ветками", diff --git a/crates/ui-egui/src/i18n/zh.rs b/crates/ui-egui/src/i18n/zh.rs index 543e6ba..db71a16 100644 --- a/crates/ui-egui/src/i18n/zh.rs +++ b/crates/ui-egui/src/i18n/zh.rs @@ -179,6 +179,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Data Table…" => "数据表…", "Scenario Manager…" => "方案管理器…", "Goal Seek…" => "单变量求解…", + "Forecast\nSheet" => "预测工作表", "Spelling" => "拼写检查", "Check\nAccessibility" => "检查\n辅助功能", "Threaded Comments" => "线程式批注", diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 0a1b244..b2e9d22 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1627,6 +1627,9 @@ fn data(app: &mut SheetApp, ui: &mut Ui) { ], ); }); + if big_button(ui, Icon::ChartLine, "Forecast\nSheet", "Forecast Sheet", false).clicked() { + app.open_dialog("forecastSheet", json!({})); + } sep(ui); ui.vertical(|ui| { if small_button(ui, Icon::Group, "Group", "Group", false).clicked() { diff --git a/docs/gaps.md b/docs/gaps.md index 7e4a315..35a8f87 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -103,8 +103,8 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- data model (`data.fromWeb`, `data.relationships` missing). **Estimate:** 60–100 h. ### 13. Analysis tools: Solver, Analysis ToolPak, Forecast Sheet (F) -- `data.solver`, `data.analysisToolPak`, `data.forecastSheet` missing (the FORECAST.ETS family the - Forecast Sheet builds on is in). **Estimate:** 18–30 h. +- `data.solver`, `data.analysisToolPak` missing. Forecast Sheet (`data.forecastSheet`) and the + FORECAST.ETS family are in; the sheet has no Forecast Start (hindcast) option yet. **Estimate:** 15–25 h. ### 14. Page Layout view and print polish (U, F) - Page Layout view landed (#329): paper view with rulers and in-place header/footer editing. @@ -162,6 +162,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- | 2026-10-11 | minor | §16: comment navigation, Show Comments, Show All Notes and Convert to Comments landed (#281) | | 2026-10-11 | minor | §7: `.xls` (BIFF8) import of values, formulas and number formats (#121) | | 2026-10-11 | minor | §3: XLSX pass-through keeps custom XML, external links, connections, query tables, printer settings, controls and OLE objects (#330, #324) | +| 2026-10-11 | minor | §13: Forecast Sheet landed (#322) | | 2026-10-11 | minor | §15: ODDFPRICE, ODDFYIELD, ODDLPRICE, ODDLYIELD added (18 functions missing) | | 2026-10-11 | minor | §15: PERCENTOF and EUROCONVERT added (20 functions missing) | | 2026-10-11 | minor | §15: BAHTTEXT added (21 functions missing) | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 836d79b..8d84e4d 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -237,7 +237,7 @@ Implemented: - `formulas.calculateNow` Calculate Now (Calculation) - `formulas.calculateSheet` Calculate Sheet (Calculation) -## Data (22 / 28, 79%) +## Data (23 / 28, 82%) Implemented: @@ -256,6 +256,7 @@ Implemented: - `data.scenarioManager` Scenario Manager (Forecast) - `data.goalSeek` Goal Seek (Forecast) - `data.dataTable` Data Table (Forecast) +- `data.forecastSheet` Forecast Sheet (Forecast) - `data.group` Group (Outline) - `data.ungroup` Ungroup (Outline) - `data.subtotal` Subtotal (Outline) @@ -270,7 +271,6 @@ Missing: - `data.stocks` Stocks (Data Types) - `data.geography` Geography (Data Types) - `data.relationships` Relationships (Data Tools) -- `data.forecastSheet` Forecast Sheet (Forecast) - `data.analyzeData` Analyze Data (Analysis) ## Review (15 / 18, 83%) diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 7d8586e..d7ce163 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (selected objects can be deleted from the keyboard) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Forecast Sheet dialog) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md). **UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured @@ -123,8 +123,8 @@ text, so AltGr symbols (e.g. `~` on a German layout) never trigger a shortcut. Format Cells, Insert Function/Formula Builder, Find and Replace, Go To and Go To Special, Sort, Name Manager, Data Validation, Paste Special, Conditional Formatting manager, Page Setup, Goal -Seek, Scenario Manager, Data Analysis and others exist. Missing or partial: Options, Solver, -Forecast Sheet, Power Query editor, Insert Object, Equation editor, Protect/Allow Edit Ranges, +Seek, Scenario Manager, Data Analysis, Forecast Sheet and others exist. Missing or partial: Options, +Solver, Power Query editor, Insert Object, Equation editor, Protect/Allow Edit Ranges, Workbook Links, Accessibility checker. ## Accessibility @@ -150,6 +150,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| | 2026-10-11 | minor | Page Layout view with rulers and in-place headers and footers (#329) | +| 2026-10-11 | minor | Forecast Sheet dialog (#322) | | 2026-10-11 | minor | Delete and Backspace now remove the selected chart, picture or shape (#213) | | 2026-10-10 | minor | Keep formula helpers inside the worksheet when scrolling (#200) | | 2026-10-11 | trivial | Theme: title-bar light/dark switch, File › Display theme, system appearance on first run (#194, #239) |