Merge pull request #786 from vangel76:feat/forecast-start-785

This commit is contained in:
Brandon Thomas committed 2026-10-13 00:08:17 -04:00
commit 8488b039a2
4 files changed
+95 -20

No files matched your search

+1
View File
@@ -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 | Ink strokes save as unfilled DrawingML paths and reopen as editable ink with their points, colour and width; icons in #551 remain a follow-up |
| 2026-10-12 | The selection outline stays visible when extended to the last row (Ctrl+Shift+Down): its sides are cut to the viewport instead of stroking a 20-million-point rectangle (#687) |
| 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 |
+90 -20
View File
@@ -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);
}
}
+1
View File
@@ -432,6 +432,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):" },
+3
View File
@@ -107,6 +107,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` missing. Forecast Sheet (`data.forecastSheet`) and the
FORECAST.ETS family are in, including Forecast Start (hindcast, #785). **Estimate:** 15–25 h.
- Solver (`data.solver`, #327) has Simplex LP with integer/binary branch and bound, a local
nonlinear method (augmented Lagrangian, not GRG), the Evolutionary method, and Answer and
Limits reports; AllDifferent constraints, the Solver Options dialog, the Sensitivity and
@@ -168,6 +170,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 | §17: ink geometry survives XLSX saves; the icon portion of #551 remains |
| 2026-10-12 | minor | §8: values on rows (Σ Values in Rows, `dataOnRows`) laid out, moved and round-tripped |
| 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. |