From 47c0af3f1b8887c6ace710b8a1fc047bb41e6b91 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 12 Oct 2026 20:34:07 +0200 Subject: [PATCH] Data: Forecast Sheet Forecast Start option (#785) Co-Authored-By: Claude Sonnet 5.5 --- ROADMAP.md | 1 + crates/engine/src/cmd/forecast.rs | 110 ++++++++++++++++++++++++------ crates/ui-egui/src/dialogs.rs | 1 + docs/gaps.md | 3 +- 4 files changed, 94 insertions(+), 21 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index e8e1c06..a928c68 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-12 | Forecast Sheet gets Forecast Start: begin the forecast inside the history to compare it with the actuals (#785) | | 2026-10-12 | File › Print selects a system printer, copies, duplex and grayscale, submits PDF jobs through craft-print on a worker and shows spooler errors; browser printing and PDF export remain available | | 2026-10-11 | Command-sequence fuzzing: random command sequences checked against undo/redo, XLSX round-trip and full recalculation; fixes for the table, legacy-array, spill, AutoSum, Watch Window and sheet-copy bugs it found | | 2026-10-11 | Password-protected workbooks open: GridCraft asks for the password and decrypts Agile and Standard encryption (#11) | diff --git a/crates/engine/src/cmd/forecast.rs b/crates/engine/src/cmd/forecast.rs index 13c120c..9795ea6 100644 --- a/crates/engine/src/cmd/forecast.rs +++ b/crates/engine/src/cmd/forecast.rs @@ -42,7 +42,7 @@ pub fn specs() -> Vec { "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.", + "{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.", has_doc, forecast_sheet )] @@ -223,13 +223,39 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result { 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 time_param = |key: &str| -> Result> { + match p.get(key) { + Some(Json::Number(n)) => Ok(n.as_f64()), + Some(Json::String(t)) if !t.trim().is_empty() => match gridcraft_core::parse::parse_input(t.trim(), sys).value { + Value::Number(x) => Ok(Some(x)), + _ => Err(bad(CMD, format!("`{key}` is not a number or a date: `{t}`"))), + }, + None | Some(Json::Null) => Ok(None), + Some(Json::String(_)) => Ok(None), + Some(_) => Err(bad(CMD, format!("`{key}` is not a number or a date"))), + } + }; + let end = time_param("end")?; + // Forecast Start: the index (into `rows`) of the first forecast point, or `rows.len()` for the + // usual forecast after the history. + let fit = match time_param("forecastStart")? { + None => rows.len(), + Some(start) => { + let last_t = times.last().copied().unwrap_or(f64::NAN); + let tol = |a: f64, b: f64| (a - b).abs() <= 1e-9 * a.abs().max(b.abs()).max(1.0); + if tol(start, last_t) || timeline.after_last(1).is_some_and(|next| tol(start, next)) { + rows.len() + } else { + let i = times + .iter() + .position(|t| tol(*t, start)) + .ok_or_else(|| bad(CMD, "`forecastStart` is not on the timeline (it is a point of the history, before its last point)"))?; + if rows.get(..i).is_none_or(|h| h.iter().filter(|r| matches!(r.1, Value::Number(_))).count() < 2) { + return Err(EngineError::Other("The forecast start needs at least two values before it to fit the forecast.".into())); + } + i + } + } }; let cap = max_periods(rows.len()); let periods = match end { @@ -252,8 +278,9 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result { // 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 fit_last = fit as u32 + 1; // 1-based row of the last point the model is fit to 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 hist = |col: u32| format!("${0}$2:${0}${fit_last}", col_letters(col)); let mut tail = format!("{},{}", opts.seasonality, opts.fill); if let Some(a) = opts.aggregate { tail.push_str(&format!(",{a}")); @@ -305,22 +332,30 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result { 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); + // The last point the model is fit to starts the forecast lines, so they join the values. + let join = fit_last - 1; // 0-based row + formula(&mut sheet, join, 2, format!("=B{fit_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); + formula(&mut sheet, join, 3, format!("=C{fit_last}"), vstyle); + formula(&mut sheet, join, 4, format!("=C{fit_last}"), vstyle); } let (tl, vl) = (hist(0), hist(1)); + let forecast_row = |sheet: &mut Sheet, r: u32| { + formula(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(sheet, r - 1, 3, format!("=C{r}-{ci}"), vstyle); + formula(sheet, r - 1, 4, format!("=C{r}+{ci}"), vstyle); + } + }; + // Hindcast: forecasts over the actuals from the start to the last history point. + for r in (fit_last + 1)..=last { + forecast_row(&mut sheet, r); + } 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); - } + forecast_row(&mut sheet, r); } if opts.statistics { put(&mut sheet, 0, stats_col, Value::text("Statistic"), bold); @@ -407,7 +442,7 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result { 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})) + Ok(json!({"sheet": sheet_name, "table": table_name, "chart": chart_id, "history": n, "periods": periods, "fitted": fit})) } /// Forecast rows needed to reach `end` (rounded up to whole steps). @@ -545,4 +580,39 @@ mod tests { s.execute("cell.set", json!({"cell": "A5", "input": "4.5"})).unwrap(); assert!(s.execute("data.forecastSheet", json!({"range": "Sheet1!A1:B17"})).is_err()); } + + #[test] + fn forecast_start_hindcasts_over_the_actuals() { + let mut s = session_with_history(); // t = 1..16 in A2:A17 + let r = s.execute("data.forecastSheet", json!({"range": "A1:B17", "forecastStart": 13, "periods": 2, "confidenceInterval": false})).unwrap(); + assert_eq!(r["fitted"], 12); + // Fit to t = 1..12 (rows 2..13); the line joins at row 13 and forecasts rows 14..19. + assert_eq!(formula(&s, 0, "C13"), "B13"); + assert_eq!(value(&s, 0, "C13"), value(&s, 0, "B13")); + assert_eq!(formula(&s, 0, "C14"), "FORECAST.ETS(A14,$B$2:$B$13,$A$2:$A$13,1,1)"); + assert_eq!(formula(&s, 0, "C19"), "FORECAST.ETS(A19,$B$2:$B$13,$A$2:$A$13,1,1)"); + // The exact seasonal series is recovered, and the actual is still shown next to it. + let c14 = value(&s, 0, "C14").as_f64().unwrap(); + assert!((c14 - 39.0).abs() < 1e-6, "{c14}"); // t = 13: 10 + 26 + 3 + assert!(value(&s, 0, "B14").as_f64().is_some()); + // Start at the last point or one step later: as without forecastStart. + for start in [json!(16), json!(17)] { + let r = s.execute("data.forecastSheet", json!({"range": "Sheet1!A1:B17", "forecastStart": start, "periods": 2})).unwrap(); + assert_eq!(r["fitted"], 16); + } + assert_eq!(formula(&s, 0, "C18"), "FORECAST.ETS(A18,$B$2:$B$17,$A$2:$A$17,1,1)"); + } + + #[test] + fn forecast_start_invalid_values_are_errors() { + let mut s = session_with_history(); + let sheets = s.doc().unwrap().wb.sheets.len(); + for bad in [json!(0), json!(2), json!(3.5), json!(18), json!(1000), json!("abc"), json!([1]), json!(1e308)] { + assert!(s.execute("data.forecastSheet", json!({"range": "A1:B17", "forecastStart": bad})).is_err(), "{bad}"); + } + assert_eq!(s.doc().unwrap().wb.sheets.len(), sheets); + // Blank or null behaves like no start; empty params unchanged. + let r = s.execute("data.forecastSheet", json!({"range": "A1:B17", "forecastStart": ""})).unwrap(); + assert_eq!(r["fitted"], 16); + } } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 5e859c6..1aabd9d 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -415,6 +415,7 @@ impl Dialog { 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):" }, + Text { key: "forecastStart", label: "Forecast start (a date or number in the history; blank: after the last point):" }, 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):" }, diff --git a/docs/gaps.md b/docs/gaps.md index 17a56b8..eefab42 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -106,7 +106,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- ### 13. Analysis tools: Solver, Analysis ToolPak, Forecast Sheet (F) - `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. + FORECAST.ETS family are in, including Forecast Start (hindcast, #785). **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. @@ -160,6 +160,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | §13: Forecast Sheet Forecast Start (hindcast) landed (#785) | | 2026-10-12 | minor | Desktop File > Print now discovers printers and submits PDF jobs through pinned craft-print with printer choice, copies, duplex and grayscale; browser printing and PDF export remain available. Remaining print parity includes page ranges and driver-specific tray/options UI; physical printer fidelity needs hardware validation. | | 2026-10-11 | minor | §6: iterative calculation for circular references landed (Maximum Iterations, Maximum Change) | | 2026-10-11 | minor | §15: trim-reference operators (`A1:.E10`, `A1.:E10`, `A1.:.E10`) parse and evaluate |