Data: Forecast Sheet Forecast Start option (#785)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
fc45b7f6d8
commit
47c0af3f1b
|
|
@ -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) |
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"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<Json> {
|
|||
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<Option<f64>> {
|
||||
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<Json> {
|
|||
// 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<Json> {
|
|||
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<Json> {
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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):" },
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
Loading…
Reference in New Issue