diff --git a/README.md b/README.md index 770b3fe..8e65dbb 100644 --- a/README.md +++ b/README.md @@ -197,7 +197,7 @@ egui frontend is a separate crate, so the UI can be swapped without touching the GridCraft is in **alpha**: the core workflow works end to end, but depth, fidelity and file compatibility are still rough. It covers 89% of the Excel ribbon commands in our catalog -([`docs/parity-checklist.md`](docs/parity-checklist.md)) and 501 of Excel's 523 worksheet functions +([`docs/parity-checklist.md`](docs/parity-checklist.md)) and 505 of Excel's 523 worksheet functions ([`docs/function-parity.md`](docs/function-parity.md)); weighted by what Excel users rely on, we estimate it is about 50% ready for real work. Next up: XLSX fidelity on real-world files, very large workbooks and multi-threaded recalc, chart depth, text in every script, and `.xls` import. Stage, diff --git a/ROADMAP.md b/ROADMAP.md index 1189fd7..6608f1d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (full re-measure against Excel for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (worksheet functions 505 / 523) · **Target:** Microsoft Excel (Microsoft 365) GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web. @@ -21,7 +21,7 @@ This page is the summary. The detail is in: | Number | Value | Kind | |---|---|---| -| **Feature breadth** (weighted) | **~75%** | estimated, anchored on two measurements: ribbon catalog 258 / 290 (89.0%), worksheet functions 501 / 523 (95.8%; 492 working, 94.1%) | +| **Feature breadth** (weighted) | **~75%** | estimated, anchored on two measurements: ribbon catalog 258 / 290 (89.0%), worksheet functions 505 / 523 (96.6%; 496 working, 94.8%) | | **Ready for real work** | **~50%** | estimated from written weights (51.6); [target-app-parity.md](docs/target-app-parity.md) | | **Mainstream practitioner** | **~50%** | estimated: typical analyst/finance user, weekly features, × 0.90 interaction × 0.92 stability × 0.88 file exchange; [method](docs/target-app-parity.md#mainstream-practitioner) | | **Essentials user** | **~63%** | estimated: core features only, × 0.92 launch/stability × 0.95 clarity × 0.90 opening sent files; [method](docs/target-app-parity.md#essentials-user) | @@ -74,7 +74,7 @@ collaboration, mobile, AI). | Area | Breadth | Ready | Hours | Doc | |---|---|---|---|---| | Formula language and calculation | ~92% | ~72% | 20–30 | [target-app-parity.md](docs/target-app-parity.md#feature-areas) | -| Worksheet functions | 95.8% measured | ~82% | 18–30 | [function-parity.md](docs/function-parity.md) | +| Worksheet functions | 96.6% measured | ~82% | 18–30 | [function-parity.md](docs/function-parity.md) | | Editing, navigation, clipboard, fill | ~90% | ~70% | 15–25 | [ui-parity.md](docs/ui-parity.md) | | Cell formatting, styles, conditional formatting | ~95% | ~80% | 8–14 | [target-app-parity.md](docs/target-app-parity.md#feature-areas) | | Sort, filter, tables, validation, outline | ~82% | ~70% | 10–16 | [target-app-parity.md](docs/target-app-parity.md#feature-areas) | @@ -131,6 +131,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-11 | FORECAST.ETS, FORECAST.ETS.CONFINT, FORECAST.ETS.SEASONALITY, FORECAST.ETS.STAT: AAA exponential smoothing forecasts (#314) | | 2026-10-10 | Ribbon translated into Japanese, Chinese, Korean, Brazilian Portuguese and Russian with system-language default (#31, #104, #82); CJK/Thai/Hebrew font fallback (#38); clear messages for encrypted and `.xls` files (#17); smooth line charts (#62). Progress docs restructured to the craftrules standard; full re-measure (this page). Initial ribbon key tips (#43), in-place text box editing (#84), copy as HTML table (#36). ODS and XLSB data import (#100, #101); recalc linear in the number of range formulas (#66); RISC-V Linux build; Flatpak repository on Pages; web cell-commit trap fixed | | 2026-10-09 | Community fixes: spill range operator `A1#`, sheet-scoped names, protection refuses locked-cell edits, Insert/Delete Cells adjust references, `DATE(1900,2,29)`, SUMIF sizing, `ROUND` at 15 digits, `_xlpm.` LAMBDA parameters in XLSX, vertical scrollbar, system appearance, border previews; v0.4.0 | | 2026-10-08 | v0.2.0 and v0.3.0 releases; Flatpak, AppImage zsync, branded DMG; desktop log file; DirectX 12 default on Windows | @@ -142,6 +143,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Worksheet functions 505 / 523 (FORECAST.ETS family); progress log row | | 2026-10-10 | minor | Readiness-by-audience table with hours to ~95% per audience; full number unchanged (additive weighted sum) | | 2026-10-10 | minor | Added Mainstream practitioner (~50%) and Essentials user (~63%); languages updated for the 10-10 merges (ribbon in ja, zh, ko, pt-BR, ru); localization 5%→10%; full number re-checked (51.6, unchanged) | | 2026-10-10 | minor | Checked against the alpha gate (six core workflows pass): stays alpha | diff --git a/crates/functions/src/ets.rs b/crates/functions/src/ets.rs new file mode 100644 index 0000000..4649f41 --- /dev/null +++ b/crates/functions/src/ets.rs @@ -0,0 +1,1005 @@ +//! The FORECAST.ETS family: additive-error, additive-trend, additive-seasonality (AAA) +//! exponential smoothing over an evenly spaced timeline. +//! +//! Model (Hyndman, Koehler, Ord & Snyder, "Forecasting with Exponential Smoothing: The State +//! Space Approach", 2008, ETS(A,A,A), component form): +//! +//! ```text +//! ŷ_t = l_{t-1} + b_{t-1} + s_{t-m} e_t = y_t − ŷ_t +//! l_t = l_{t-1} + b_{t-1} + α·e_t +//! b_t = b_{t-1} + α·β·e_t +//! s_t = s_{t-m} + γ·e_t +//! ``` +//! +//! Initial states follow the book's heuristic (§2.6.1): seasonal indices from a classical +//! decomposition (centred moving average) of the first seasons, level and trend from a straight +//! line fitted to the first ten seasonally adjusted values. α, β and γ (each in [0, 1]) minimise +//! the sum of squared one-step errors: a coarse grid, then a Nelder–Mead simplex clamped to the +//! box. Excel does not document its optimiser, so fitted parameters (and the last digits of +//! forecasts on noisy data) can differ from Excel's; exact data (straight lines, exactly +//! repeating seasons) forecast exactly either way. +//! +//! Seasonality detection: the autocorrelation of the linearly detrended series; the highest local +//! peak above the 95% white-noise bound (2/√n) at a lag of at most n/2 (and at most 8,760). +//! +//! 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::{CellError, DateSystem, Value}; + +use crate::special::norm_inv; +use crate::util::{R, arg, as_array, has, num, num_val}; +use crate::{Arg, Ctx, FnSpec}; + +type C<'a> = &'a mut dyn Ctx; + +/// Longest timeline (after completing missing points) the functions accept. +pub(crate) const MAX_POINTS: usize = 200_000; +/// Longest seasonality Excel supports (hours in a year). +pub(crate) const MAX_SEASON: usize = 8_760; +/// Share of the timeline's points that may be missing. +const MAX_MISSING: f64 = 0.3; +/// Seasonally adjusted values the initial level and trend are fitted to. +const INIT_POINTS: usize = 10; +const NM_ITERATIONS: usize = 400; + +// --------------------------------------------------------------------------------------------- +// Options +// --------------------------------------------------------------------------------------------- + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Season { + Detect, + None, + Fixed(usize), +} + +/// Duplicate time stamps are combined with this function. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Aggregate { + Average, + Count, + CountA, + Max, + Median, + Min, + Sum, +} + +#[derive(Clone, Copy, Debug)] +pub(crate) struct Options { + pub seasonality: Season, + /// `true`: missing points are interpolated from their neighbours; `false`: they are zero. + pub interpolate: bool, + pub aggregate: Aggregate, +} + +impl Default for Options { + fn default() -> Self { + Options { seasonality: Season::Detect, interpolate: true, aggregate: Aggregate::Average } + } +} + +fn whole(a: &[Arg], i: usize) -> R> { + if !has(a, i) { + return Ok(None); + } + let v = num(a, i)?; + if !v.is_finite() { + return Err(CellError::Num); + } + Ok(Some(v.trunc())) +} + +/// `[seasonality]`: 1 (default) detects, 0 means none, 2–8,760 is the season length. +pub(crate) fn season_arg(a: &[Arg], i: usize) -> R { + match whole(a, i)? { + None => Ok(Season::Detect), + Some(1.0) => Ok(Season::Detect), + Some(0.0) => Ok(Season::None), + Some(v) if v >= 2.0 && v <= MAX_SEASON as f64 => Ok(Season::Fixed(v as usize)), + Some(_) => Err(CellError::Num), + } +} + +/// `[data_completion]`: 1 (default) interpolates, 0 fills zeros. +fn completion_arg(a: &[Arg], i: usize) -> R { + match whole(a, i)? { + None => Ok(true), + Some(1.0) => Ok(true), + Some(0.0) => Ok(false), + Some(_) => Err(CellError::Num), + } +} + +/// `[aggregation]`: 0 or 1 AVERAGE (default), 2 COUNT, 3 COUNTA, 4 MAX, 5 MEDIAN, 6 MIN, 7 SUM. +fn aggregate_arg(a: &[Arg], i: usize) -> R { + Ok(match whole(a, i)? { + None => Aggregate::Average, + Some(v) => match v as i64 { + 0 | 1 => Aggregate::Average, + 2 => Aggregate::Count, + 3 => Aggregate::CountA, + 4 => Aggregate::Max, + 5 => Aggregate::Median, + 6 => Aggregate::Min, + 7 => Aggregate::Sum, + _ => return Err(CellError::Num), + }, + }) +} + +// --------------------------------------------------------------------------------------------- +// Timeline +// --------------------------------------------------------------------------------------------- + +/// How timeline values map to point positions (0 = first point). +#[derive(Clone, Copy, Debug)] +enum Axis { + /// A constant numeric step. + Linear { t0: f64, step: f64 }, + /// Whole-day dates a constant number of calendar months apart, all on the same day of the + /// month (`day = Some(d)`) or all on the last day of their month (`day = None`). + Months { sys: DateSystem, month0: i64, step: i64, day: Option }, +} + +fn month_parts(sys: DateSystem, t: f64) -> Option<(i64, f64)> { + let d = datetime_from_serial(sys, t.floor())?; + if d.day == 0 { + return None; + } + Some((i64::from(d.year) * 12 + i64::from(d.month) - 1, f64::from(d.day))) +} + +impl Axis { + fn position(&self, t: f64) -> Option { + match *self { + Axis::Linear { t0, step } => Some((t - t0) / step), + Axis::Months { sys, month0, step, day } => { + let d = datetime_from_serial(sys, t.floor())?; + if d.day == 0 { + return None; + } + let month = i64::from(d.year) * 12 + i64::from(d.month) - 1; + let dim = f64::from(days_in_month_in(sys, d.year, d.month)); + if dim <= 0.0 { + return None; + } + let anchor = day.map_or(dim, |a| f64::from(a).min(dim)); + let offset = (f64::from(d.day) - anchor + (t - t.floor())) / dim; + Some(((month - month0) as f64 + offset) / step as f64) + } + } + } +} + +fn gcd(mut a: i64, mut b: i64) -> i64 { + while b != 0 { + let r = a % b; + a = b; + b = r; + } + a.abs() +} + +/// The axis of sorted, distinct time stamps: a constant step (missing points allowed) or, for +/// dates, a constant number of calendar months. +fn detect_axis(times: &[f64], sys: DateSystem) -> R { + let (Some(&t0), Some(&t1)) = (times.first(), times.get(1)) else { return Err(CellError::Num) }; + let step = times.windows(2).map(|w| w.get(1).copied().unwrap_or(t1) - w.first().copied().unwrap_or(t0)).fold(f64::INFINITY, f64::min); + if step.is_finite() && step > 0.0 { + let even = times.windows(2).all(|w| match (w.first(), w.get(1)) { + (Some(a), Some(b)) => { + let r = (b - a) / step; + (r - r.round()).abs() <= 1e-6 + } + _ => true, + }); + if even { + return Ok(Axis::Linear { t0, step }); + } + } + // Calendar months: whole days on one day of the month (or all month ends). + let mut months = Vec::with_capacity(times.len()); + let mut same_day: Option = None; + let mut all_same = true; + let mut all_end = true; + for &t in times { + if t.fract() != 0.0 { + return Err(CellError::Num); + } + let d = datetime_from_serial(sys, t).ok_or(CellError::Num)?; + if d.day == 0 { + return Err(CellError::Num); + } + months.push(i64::from(d.year) * 12 + i64::from(d.month) - 1); + match same_day { + None => same_day = Some(d.day), + Some(x) if x != d.day => all_same = false, + _ => {} + } + if d.day != days_in_month_in(sys, d.year, d.month) { + all_end = false; + } + } + let day = if all_same { + same_day + } else if all_end { + None + } else { + return Err(CellError::Num); + }; + let mut g = 0i64; + for w in months.windows(2) { + if let (Some(a), Some(b)) = (w.first(), w.get(1)) { + let d = b - a; + if d <= 0 { + return Err(CellError::Num); + } + g = gcd(g, d); + } + } + let month0 = month_parts(sys, t0).ok_or(CellError::Num)?.0; + if g <= 0 { + return Err(CellError::Num); + } + Ok(Axis::Months { sys, month0, step: g, day }) +} + +/// An evenly spaced series built from a values/timeline pair. +#[derive(Clone, Debug)] +pub(crate) struct Series { + pub y: Vec, + axis: Axis, + /// The timeline's step in timeline units (the average step for calendar months). + pub step: f64, +} + +impl Series { + /// Position of a time stamp relative to the last point (0 = last point, 1 = next step). + pub fn horizon(&self, t: f64) -> R { + let p = self.axis.position(t).ok_or(CellError::Num)?; + let h = p - (self.y.len() as f64 - 1.0); + if !h.is_finite() || h < -1e-9 { + return Err(CellError::Num); + } + Ok(h.max(0.0)) + } +} + +fn aggregate(kind: Aggregate, xs: &mut [f64]) -> Option { + let n = xs.len() as f64; + match kind { + Aggregate::Count | Aggregate::CountA => Some(n), + _ if xs.is_empty() => None, + Aggregate::Average => Some(xs.iter().sum::() / n), + Aggregate::Sum => Some(xs.iter().sum()), + Aggregate::Max => xs.iter().copied().reduce(f64::max), + Aggregate::Min => xs.iter().copied().reduce(f64::min), + Aggregate::Median => { + xs.sort_by(|a, b| a.total_cmp(b)); + let k = xs.len() / 2; + let hi = xs.get(k).copied()?; + if xs.len() % 2 == 1 { Some(hi) } else { Some((xs.get(k.checked_sub(1)?).copied()? + hi) / 2.0) } + } + } +} + +/// Builds the series: pairs values with time stamps, sorts, aggregates duplicates, checks the +/// step and completes missing points. +pub(crate) fn series(values: &Value, timeline: &Value, opts: &Options, sys: DateSystem) -> R { + let va = as_array(values); + let ta = as_array(timeline); + if va.data.len() != ta.data.len() { + return Err(CellError::NA); + } + if va.data.len() > MAX_POINTS { + return Err(CellError::Num); + } + let mut points: Vec<(f64, Option)> = Vec::with_capacity(va.data.len()); + for (t, v) in ta.data.iter().zip(va.data.iter()) { + if let Value::Error(e) = t { + return Err(*e); + } + if let Value::Error(e) = v { + return Err(*e); + } + let value = match v { + Value::Number(x) => Some(*x), + Value::Empty => None, + _ => return Err(CellError::Value), + }; + let time = match t { + Value::Number(x) if x.is_finite() => *x, + // A blank row (no time stamp, no value) is ignored. + Value::Empty if value.is_none() => continue, + _ => return Err(CellError::Value), + }; + points.push((time, value)); + } + points.sort_by(|a, b| a.0.total_cmp(&b.0)); + let mut times: Vec = points.iter().map(|p| p.0).collect(); + times.dedup(); + if times.len() < 2 { + return Err(CellError::Num); + } + let axis = detect_axis(×, sys)?; + let (Some(&first), Some(&last)) = (times.first(), times.last()) else { return Err(CellError::Num) }; + let span = axis.position(last).ok_or(CellError::Num)?.round(); + if !(span >= 1.0 && span < MAX_POINTS as f64) { + return Err(CellError::Num); + } + let len = span as usize + 1; + // Group the (sorted) points by position. + let mut slots: Vec> = vec![None; len]; + let mut group: Vec = Vec::new(); + let mut group_at: Option = None; + let flush = |at: Option, group: &mut Vec, slots: &mut Vec>| { + if let Some(i) = at + && let Some(s) = slots.get_mut(i) + { + *s = aggregate(opts.aggregate, group); + } + group.clear(); + }; + for &(t, v) in &points { + let p = axis.position(t).ok_or(CellError::Num)?.round(); + if !(p >= 0.0 && p < len as f64) { + return Err(CellError::Num); + } + let p = p as usize; + if group_at != Some(p) { + flush(group_at, &mut group, &mut slots); + group_at = Some(p); + } + if let Some(x) = v { + group.push(x); + } + } + flush(group_at, &mut group, &mut slots); + let known = slots.iter().filter(|s| s.is_some()).count(); + if known == 0 || (len - known) as f64 > MAX_MISSING * len as f64 { + return Err(CellError::Num); + } + let y = if opts.interpolate { interpolate(&slots) } else { slots.iter().map(|s| s.unwrap_or(0.0)).collect() }; + let step = match axis { + Axis::Linear { step, .. } => step, + Axis::Months { .. } => (last - first) / span, + }; + Ok(Series { y, axis, step }) +} + +/// Missing points become the straight line between their known neighbours (the nearest known +/// value at either end). +fn interpolate(slots: &[Option]) -> Vec { + let mut out = Vec::with_capacity(slots.len()); + let mut prev: Option<(usize, f64)> = None; + for (i, s) in slots.iter().enumerate() { + if let Some(v) = s { + out.push(*v); + prev = Some((i, *v)); + continue; + } + let next = slots.iter().enumerate().skip(i + 1).find_map(|(j, s)| s.map(|v| (j, v))); + out.push(match (prev, next) { + (Some((a, va)), Some((b, vb))) => va + (vb - va) * (i - a) as f64 / (b - a) as f64, + (Some((_, v)), None) | (None, Some((_, v))) => v, + (None, None) => 0.0, + }); + } + out +} + +// --------------------------------------------------------------------------------------------- +// Seasonality detection +// --------------------------------------------------------------------------------------------- + +/// Residuals of the least-squares line through `y` (x = 0, 1, 2…). +fn detrend(y: &[f64]) -> Vec { + let (a, b) = line(y.iter().enumerate().map(|(i, v)| (i as f64, *v))); + y.iter().enumerate().map(|(i, v)| v - (a + b * i as f64)).collect() +} + +/// (intercept, slope) of the least-squares line through the points (slope 0 for one point). +fn line(points: impl Iterator + Clone) -> (f64, f64) { + let (mut n, mut sx, mut sy) = (0.0, 0.0, 0.0); + for (x, y) in points.clone() { + n += 1.0; + sx += x; + sy += y; + } + if n == 0.0 { + return (0.0, 0.0); + } + let (mx, my) = (sx / n, sy / n); + let (mut sxy, mut sxx) = (0.0, 0.0); + for (x, y) in points { + sxy += (x - mx) * (y - my); + sxx += (x - mx) * (x - mx); + } + let b = if sxx > 0.0 { sxy / sxx } else { 0.0 }; + (my - b * mx, b) +} + +/// The detected season length, 0 when the series shows none. +pub(crate) fn detect_season(y: &[f64]) -> usize { + let n = y.len(); + if n < 4 { + return 0; + } + let r = detrend(y); + let var: f64 = r.iter().map(|v| v * v).sum(); + let scale: f64 = y.iter().map(|v| v * v).sum::(); + if !(var.is_finite() && var > 1e-20 * scale.max(f64::MIN_POSITIVE)) { + return 0; + } + let max_lag = (n / 2).min(MAX_SEASON); + let acf: Vec = (0..=max_lag.saturating_add(1).min(n.saturating_sub(1))) + .map(|k| r.iter().skip(k).zip(r.iter()).map(|(a, b)| a * b).sum::() / var) + .collect(); + let bound = 2.0 / (n as f64).sqrt(); + let mut best = 0usize; + let mut best_r = bound; + for k in 2..=max_lag { + let (Some(&prev), Some(&here)) = (acf.get(k - 1), acf.get(k)) else { break }; + let next = acf.get(k + 1).copied().unwrap_or(f64::NEG_INFINITY); + if here > prev && here >= next && here > best_r { + best = k; + best_r = here; + } + } + best +} + +// --------------------------------------------------------------------------------------------- +// Model +// --------------------------------------------------------------------------------------------- + +#[derive(Clone, Debug)] +struct Init { + level: f64, + trend: f64, + /// Seasonal state for positions 0..m (empty without seasonality). + season: Vec, +} + +/// Initial states (Hyndman et al. 2008, §2.6.1). +fn initial_states(y: &[f64], m: usize) -> Init { + let n = y.len(); + let mut season = vec![0.0; m]; + if m >= 2 { + if n >= 2 * m { + // Classical decomposition of the first seasons: centred moving average as the trend. + let len = n.min(4 * m).max(2 * m); + let w = y.get(..len).unwrap_or(y); + let mut prefix = Vec::with_capacity(w.len() + 1); + prefix.push(0.0); + let mut acc = 0.0; + for v in w { + acc += v; + prefix.push(acc); + } + let sum = |a: usize, b: usize| prefix.get(b).copied().unwrap_or(0.0) - prefix.get(a).copied().unwrap_or(0.0); + let h = m / 2; + let mut totals = vec![0.0; m]; + let mut counts = vec![0usize; m]; + for t in h..w.len().saturating_sub(h) { + let ma = if m % 2 == 1 { + sum(t - h, t + h + 1) / m as f64 + } else { + let ends = w.get(t - h).copied().unwrap_or(0.0) + w.get(t + h).copied().unwrap_or(0.0); + (sum(t - h + 1, t + h) + ends / 2.0) / m as f64 + }; + if let (Some(tot), Some(c), Some(v)) = (totals.get_mut(t % m), counts.get_mut(t % m), w.get(t)) { + *tot += v - ma; + *c += 1; + } + } + for ((s, tot), c) in season.iter_mut().zip(&totals).zip(&counts) { + *s = if *c > 0 { tot / *c as f64 } else { 0.0 }; + } + } else { + // Under two seasons: deviations from a straight line through the data. + let r = detrend(y); + let mut counts = vec![0usize; m]; + for (t, v) in r.iter().enumerate() { + if let (Some(s), Some(c)) = (season.get_mut(t % m), counts.get_mut(t % m)) { + *s += v; + *c += 1; + } + } + for (s, c) in season.iter_mut().zip(&counts) { + if *c > 0 { + *s /= *c as f64; + } + } + } + let mean = season.iter().sum::() / m as f64; + for s in &mut season { + *s -= mean; + } + } + let k = n.min(INIT_POINTS); + let adjusted = y.iter().take(k).enumerate().map(|(t, v)| { + let s = if m >= 2 { season.get(t % m).copied().unwrap_or(0.0) } else { 0.0 }; + ((t + 1) as f64, v - s) + }); + let (level, trend) = line(adjusted); + Init { level, trend, season } +} + +/// Smoothing parameters (α, β, γ). +type Params = [f64; 3]; + +#[derive(Clone, Debug)] +struct State { + level: f64, + trend: f64, + season: Vec, + sse: f64, +} + +/// Runs the filter over the data; `errors` collects the one-step errors when given. +fn filter(y: &[f64], init: &Init, p: Params, mut errors: Option<&mut Vec>) -> State { + let [alpha, beta, gamma] = p; + let m = init.season.len(); + let (mut l, mut b) = (init.level, init.trend); + let mut season = init.season.clone(); + let mut sse = 0.0; + for (t, v) in y.iter().enumerate() { + let slot = if m > 0 { season.get_mut(t % m) } else { None }; + let s = slot.as_ref().map_or(0.0, |s| **s); + let e = v - (l + b + s); + if let Some(slot) = slot { + *slot = s + gamma * e; + } + l = l + b + alpha * e; + b += alpha * beta * e; + sse += e * e; + if let Some(errs) = errors.as_deref_mut() { + errs.push(e); + } + } + State { level: l, trend: b, season, sse } +} + +fn objective(y: &[f64], init: &Init, p: Params) -> f64 { + let sse = filter(y, init, p, None).sse; + if sse.is_finite() { sse } else { f64::INFINITY } +} + +fn clamp(p: Params, active: usize) -> Params { + let mut q = [0.0; 3]; + for (i, (o, v)) in q.iter_mut().zip(p).enumerate() { + *o = if i < active { v.clamp(0.0, 1.0) } else { 0.0 }; + } + q +} + +/// Minimises the SSE over the active parameters (α, β and, with seasonality, γ) in [0, 1]. +fn optimise(y: &[f64], init: &Init) -> Params { + let active = if init.season.is_empty() { 2 } else { 3 }; + let f = |p: Params| objective(y, init, clamp(p, active)); + // Coarse grid. + const GRID: [f64; 5] = [0.05, 0.25, 0.5, 0.75, 0.95]; + let mut best: (Params, f64) = ([0.5, 0.1, 0.1], f64::INFINITY); + for a in GRID { + for b in GRID { + for g in if active == 3 { &GRID[..] } else { &GRID[..1] } { + let p = clamp([a, b, *g], active); + let v = f(p); + if v < best.1 { + best = (p, v); + } + } + } + } + // Nelder–Mead from the best grid point, clamped to the box. + let mut simplex: Vec<(Params, f64)> = vec![best]; + for i in 0..active { + let mut p = best.0; + if let Some(x) = p.get_mut(i) { + *x = if *x > 0.5 { *x - 0.1 } else { *x + 0.1 }; + } + let p = clamp(p, active); + simplex.push((p, f(p))); + } + let lerp = |a: Params, b: Params, t: f64| -> Params { + let mut o = [0.0; 3]; + for ((o, x), y) in o.iter_mut().zip(a).zip(b) { + *o = x + t * (y - x); + } + clamp(o, active) + }; + for _ in 0..NM_ITERATIONS { + simplex.sort_by(|a, b| a.1.total_cmp(&b.1)); + let (Some(&lo), Some(&hi)) = (simplex.first(), simplex.last()) else { break }; + let second = simplex.len().checked_sub(2).and_then(|i| simplex.get(i)).map_or(hi.1, |s| s.1); + let size = simplex.iter().map(|s| s.0.iter().zip(lo.0).map(|(a, b)| (a - b).abs()).fold(0.0, f64::max)).fold(0.0, f64::max); + if (hi.1 - lo.1).abs() <= 1e-12 * lo.1.abs() + 1e-300 || size < 1e-7 { + break; + } + let k = simplex.len().saturating_sub(1).max(1) as f64; + let mut c = [0.0; 3]; + for s in simplex.iter().take(simplex.len().saturating_sub(1)) { + for (ci, x) in c.iter_mut().zip(s.0) { + *ci += x / k; + } + } + let reflected = lerp(c, hi.0, -1.0); + let fr = f(reflected); + let replace = if fr < lo.1 { + let expanded = lerp(c, hi.0, -2.0); + let fe = f(expanded); + Some(if fe < fr { (expanded, fe) } else { (reflected, fr) }) + } else if fr < second { + Some((reflected, fr)) + } else { + let contracted = lerp(c, hi.0, 0.5); + let fc = f(contracted); + if fc < hi.1 { Some((contracted, fc)) } else { None } + }; + match replace { + Some(v) => { + if let Some(last) = simplex.last_mut() { + *last = v; + } + } + None => { + for s in simplex.iter_mut().skip(1) { + let p = lerp(lo.0, s.0, 0.5); + *s = (p, f(p)); + } + } + } + } + simplex.sort_by(|a, b| a.1.total_cmp(&b.1)); + clamp(simplex.first().map_or(best.0, |s| s.0), active) +} + +/// A fitted model. +#[derive(Clone, Debug)] +pub(crate) struct Fit { + pub alpha: f64, + pub beta: f64, + pub gamma: f64, + pub m: usize, + n: usize, + state: State, + pub errors: Vec, +} + +pub(crate) fn fit(y: &[f64], m: usize) -> Fit { + let m = if m >= 2 { m } else { 0 }; + let init = initial_states(y, m); + let p = optimise(y, &init); + let mut errors = Vec::with_capacity(y.len()); + let state = filter(y, &init, p, Some(&mut errors)); + let [alpha, beta, gamma] = p; + Fit { alpha, beta, gamma, m, n: y.len(), state, errors } +} + +impl Fit { + fn at(&self, k: f64) -> f64 { + let s = if self.m > 0 { + let idx = ((self.n as f64 - 1.0) + k).rem_euclid(self.m as f64); + self.state.season.get(idx as usize).copied().unwrap_or(0.0) + } else { + 0.0 + }; + self.state.level + k * self.state.trend + s + } + + /// Point forecast `h` steps after the last point (fractional steps interpolate). + pub fn forecast(&self, h: f64) -> f64 { + let lo = h.floor(); + let a = self.at(lo); + if h == lo { a } else { a + (h - lo) * (self.at(lo + 1.0) - a) } + } + + /// Standard deviation of the forecast error `h` steps ahead. + pub fn forecast_sd(&self, h: f64) -> f64 { + let sigma2 = self.state.sse / self.n.max(1) as f64; + let k = (h.ceil().max(1.0)) - 1.0; + let (a, b, g) = (self.alpha, self.alpha * self.beta, self.gamma); + // Σ_{j=1}^{k} (a + b·j)² + seasonal terms, in closed form. + let mut sum = k * a * a + a * b * k * (k + 1.0) + b * b * k * (k + 1.0) * (2.0 * k + 1.0) / 6.0; + if self.m > 0 { + let m = self.m as f64; + let q = (k / m).floor(); + sum += 2.0 * g * (q * a + b * m * q * (q + 1.0) / 2.0) + g * g * q; + } + (sigma2 * (1.0 + sum)).max(0.0).sqrt() + } +} + +// --------------------------------------------------------------------------------------------- +// Worksheet functions +// --------------------------------------------------------------------------------------------- + +struct Prepared { + series: Series, + m: usize, +} + +fn prepare(a: &[Arg], c: C, values: usize, timeline: usize, opts: Options) -> R { + let series = series(&arg(a, values)?.value, &arg(a, timeline)?.value, &opts, c.date_system())?; + let m = match opts.seasonality { + Season::Detect => detect_season(&series.y), + Season::None => 0, + Season::Fixed(m) => m, + }; + Ok(Prepared { series, m }) +} + +fn options(a: &[Arg], season: Option, completion: usize, aggregation: usize) -> R { + Ok(Options { + seasonality: match season { + Some(i) => season_arg(a, i)?, + None => Season::Detect, + }, + interpolate: completion_arg(a, completion)?, + aggregate: aggregate_arg(a, aggregation)?, + }) +} + +fn forecast_ets(a: &[Arg], c: C) -> R { + let target = num(a, 0)?; + let opts = options(a, Some(3), 4, 5)?; + let p = prepare(a, c, 1, 2, opts)?; + let h = p.series.horizon(target)?; + num_val(fit(&p.series.y, p.m).forecast(h)) +} + +fn forecast_ets_confint(a: &[Arg], c: C) -> R { + let target = num(a, 0)?; + let level = if has(a, 3) { num(a, 3)? } else { 0.95 }; + if !(level > 0.0 && level < 1.0) { + return Err(CellError::Num); + } + let opts = options(a, Some(4), 5, 6)?; + let p = prepare(a, c, 1, 2, opts)?; + let h = p.series.horizon(target)?; + let z = norm_inv((1.0 + level) / 2.0); + num_val(z * fit(&p.series.y, p.m).forecast_sd(h)) +} + +fn forecast_ets_seasonality(a: &[Arg], c: C) -> R { + let opts = options(a, None, 2, 3)?; + let p = prepare(a, c, 0, 1, opts)?; + num_val(p.m as f64) +} + +fn forecast_ets_stat(a: &[Arg], c: C) -> R { + let kind = num(a, 2)?; + if !kind.is_finite() || !(1.0..9.0).contains(&kind.trunc()) { + return Err(CellError::Num); + } + let kind = kind.trunc() as u8; + let opts = options(a, Some(3), 4, 5)?; + let p = prepare(a, c, 0, 1, opts)?; + if kind == 8 { + return num_val(p.series.step); + } + let y = &p.series.y; + let f = fit(y, p.m); + let n = f.errors.len().max(1) as f64; + let mae = f.errors.iter().map(|e| e.abs()).sum::() / n; + num_val(match kind { + 1 => f.alpha, + 2 => f.beta, + 3 => f.gamma, + 4 => { + let naive: Vec = y.windows(2).filter_map(|w| Some((w.get(1)? - w.first()?).abs())).collect(); + let d = naive.iter().sum::() / naive.len().max(1) as f64; + if d == 0.0 { + return Err(CellError::Div0); + } + mae / d + } + 5 => { + let terms: Vec = y + .iter() + .zip(&f.errors) + .filter_map(|(v, e)| { + let denom = (v.abs() + (v - e).abs()) / 2.0; + (denom > 0.0).then(|| e.abs() / denom) + }) + .collect(); + terms.iter().sum::() / terms.len().max(1) as f64 + } + 6 => mae, + 7 => (f.errors.iter().map(|e| e * e).sum::() / n).sqrt(), + _ => return Err(CellError::Num), + }) +} + +const S_ETS: &[bool] = &[true, false, false, true, true, true, true]; +const S_ETS_SEASON: &[bool] = &[false, false, true, true]; +const S_ETS_STAT: &[bool] = &[false, false, true, true, true, true]; + +pub(crate) fn specs() -> Vec { + vec![ + f!( + "FORECAST.ETS", + 3, + 6, + Statistical, + S_ETS, + "FORECAST.ETS(target_date, values, timeline, [seasonality], [data_completion], [aggregation])", + "Forecasts a value by additive (AAA) exponential smoothing.", + forecast_ets + ), + f!( + "FORECAST.ETS.CONFINT", + 3, + 7, + Statistical, + S_ETS, + "FORECAST.ETS.CONFINT(target_date, values, timeline, [confidence_level], [seasonality], [data_completion], [aggregation])", + "Half-width of the confidence interval around a FORECAST.ETS value.", + forecast_ets_confint + ), + f!( + "FORECAST.ETS.SEASONALITY", + 2, + 4, + Statistical, + S_ETS_SEASON, + "FORECAST.ETS.SEASONALITY(values, timeline, [data_completion], [aggregation])", + "Length of the repeating pattern detected in a time series.", + forecast_ets_seasonality + ), + f!( + "FORECAST.ETS.STAT", + 3, + 6, + Statistical, + S_ETS_STAT, + "FORECAST.ETS.STAT(values, timeline, statistic_type, [seasonality], [data_completion], [aggregation])", + "A statistic of the FORECAST.ETS model: 1 alpha, 2 beta, 3 gamma, 4 MASE, 5 SMAPE, 6 MAE, 7 RMSE, 8 step size.", + forecast_ets_stat + ), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::util::testutil::*; + + fn range(xs: &[f64]) -> Arg { + rf(col(xs)) + } + + fn timeline(n: usize) -> Vec { + (1..=n).map(|t| t as f64).collect() + } + + /// y = 3 + 2t plus a four-step season, over `n` points. + fn seasonal(n: usize) -> Vec { + let pattern = [4.0, -2.0, 7.0, -9.0]; + (1..=n).map(|t| 3.0 + 2.0 * t as f64 + pattern[(t - 1) % 4]).collect() + } + + #[test] + fn straight_line_forecasts_exactly() { + let ys: Vec = (1..=20).map(|t| 3.0 + 2.0 * t as f64).collect(); + let ts = timeline(20); + close_tol(ev("FORECAST.ETS", vec![n(25.0), range(&ys), range(&ts)]), 53.0, 1e-9); + close_tol(ev("FORECAST.ETS", vec![n(20.5), range(&ys), range(&ts), n(0.0)]), 44.0, 1e-9); + close(ev("FORECAST.ETS.SEASONALITY", vec![range(&ys), range(&ts)]), 0.0); + close_tol(ev("FORECAST.ETS.CONFINT", vec![n(25.0), range(&ys), range(&ts)]), 0.0, 1e-9); + close(ev("FORECAST.ETS.STAT", vec![range(&ys), range(&ts), n(8.0)]), 1.0); + close_tol(ev("FORECAST.ETS.STAT", vec![range(&ys), range(&ts), n(6.0)]), 0.0, 1e-9); + // Target dates are lifted over arrays. + let v = ev("FORECAST.ETS", vec![av(row(&[21.0, 22.0])), range(&ys), range(&ts)]); + let r = rows_of(&v); + close_tol(r[0][0].clone(), 45.0, 1e-9); + close_tol(r[0][1].clone(), 47.0, 1e-9); + } + + #[test] + fn seasonal_series_is_detected_and_continued() { + let ys = seasonal(24); + let ts = timeline(24); + close(ev("FORECAST.ETS.SEASONALITY", vec![range(&ys), range(&ts)]), 4.0); + // t = 27 is the third position of the pattern: 3 + 54 + 7. + close_tol(ev("FORECAST.ETS", vec![n(27.0), range(&ys), range(&ts)]), 64.0, 1e-7); + close_tol(ev("FORECAST.ETS", vec![n(28.0), range(&ys), range(&ts), n(4.0)]), 50.0, 1e-7); + // Timeline order does not matter. + let mut rev_y = ys.clone(); + let mut rev_t = ts.clone(); + rev_y.reverse(); + rev_t.reverse(); + close_tol(ev("FORECAST.ETS", vec![n(27.0), range(&rev_y), range(&rev_t)]), 64.0, 1e-7); + } + + #[test] + fn noisy_series_statistics_are_consistent() { + // A seasonal series with deterministic pseudo-noise. + let ys: Vec = seasonal(48).iter().enumerate().map(|(i, v)| v + ((i * 37 % 11) as f64 - 5.0) * 0.4).collect(); + let ts = timeline(48); + let stat = |k: f64| match ev("FORECAST.ETS.STAT", vec![range(&ys), range(&ts), n(k)]) { + Value::Number(x) => x, + other => panic!("statistic {k}: {other:?}"), + }; + for k in [1.0, 2.0, 3.0] { + assert!((0.0..=1.0).contains(&stat(k)), "parameter {k} out of range"); + } + let (mase, smape, mae, rmse) = (stat(4.0), stat(5.0), stat(6.0), stat(7.0)); + assert!(mae > 0.0 && rmse >= mae && mase > 0.0 && smape > 0.0 && smape < 2.0); + let conf = |t: f64, c: f64| match ev("FORECAST.ETS.CONFINT", vec![n(t), range(&ys), range(&ts), n(c)]) { + Value::Number(x) => x, + other => panic!("confint: {other:?}"), + }; + assert!(conf(49.0, 0.95) > 0.0); + assert!(conf(60.0, 0.95) > conf(49.0, 0.95), "the interval widens with the horizon"); + assert!(conf(49.0, 0.99) > conf(49.0, 0.95)); + // The forecast stays close to the noise-free continuation. + match ev("FORECAST.ETS", vec![n(51.0), range(&ys), range(&ts)]) { + Value::Number(x) => assert!((x - (3.0 + 102.0 + 7.0)).abs() < 3.0, "forecast {x}"), + other => panic!("{other:?}"), + } + } + + #[test] + fn monthly_dates_missing_points_and_duplicates() { + // First of each month, 2024-01 … 2025-12 (serials of 1 Jan 2024 = 45292). + let mut ts = Vec::new(); + let mut serial = 45292.0; + for i in 0..24 { + ts.push(serial); + let (y, m) = (2024 + i / 12, (i % 12) as u32 + 1); + serial += f64::from(gridcraft_core::date::days_in_month(y, m)); + } + let ys: Vec = (0..24).map(|i| 100.0 + 5.0 * i as f64).collect(); + // 2026-01-01 = 46023, 2026-03-01 = 46082. + close_tol(ev("FORECAST.ETS", vec![n(46023.0), range(&ys), range(&ts), n(0.0)]), 220.0, 1e-9); + close_tol(ev("FORECAST.ETS", vec![n(46082.0), range(&ys), range(&ts), n(0.0)]), 230.0, 1e-9); + // Missing points are interpolated (or zero with data_completion 0). + let gaps_t: Vec = [1.0, 2.0, 4.0, 5.0, 6.0, 8.0, 9.0, 10.0].to_vec(); + let gaps_y: Vec = gaps_t.iter().map(|t| 10.0 * t).collect(); + close_tol(ev("FORECAST.ETS", vec![n(12.0), range(&gaps_y), range(&gaps_t), n(0.0)]), 120.0, 1e-9); + assert_ne!(ev("FORECAST.ETS", vec![n(12.0), range(&gaps_y), range(&gaps_t), n(0.0), n(0.0)]), Value::Number(120.0)); + // Duplicate time stamps are averaged by default, summed with aggregation 7. + let dup_t = [1.0, 2.0, 2.0, 3.0, 4.0, 5.0]; + let dup_y = [2.0, 3.0, 5.0, 6.0, 8.0, 10.0]; + close_tol(ev("FORECAST.ETS", vec![n(6.0), range(&dup_y), range(&dup_t), n(0.0)]), 12.0, 1e-9); + close_tol(ev("FORECAST.ETS.STAT", vec![range(&dup_y), range(&dup_t), n(8.0)]), 1.0, 1e-12); + assert_ne!(ev("FORECAST.ETS", vec![n(6.0), range(&dup_y), range(&dup_t), n(0.0), n(1.0), n(7.0)]), Value::Number(12.0)); + } + + #[test] + fn documented_errors() { + let ys = seasonal(12); + let ts = timeline(12); + let ets = |args: Vec| ev("FORECAST.ETS", args); + // Target before the end of the timeline. + is_err(ets(vec![n(5.0), range(&ys), range(&ts)]), CellError::Num); + // Timeline and values of different sizes. + is_err(ets(vec![n(13.0), range(&ys), range(&ts[..11])]), CellError::NA); + // No constant step. + is_err(ets(vec![n(5.0), range(&[1.0, 2.0, 3.0]), range(&[1.0, 2.0, 4.5])]), CellError::Num); + // Seasonality, completion, aggregation out of range. + for (i, bad) in [(3, 8761.0), (3, -1.0), (4, 2.0), (5, 8.0)] { + let mut args = vec![n(13.0), range(&ys), range(&ts), n(1.0), n(1.0), n(0.0)]; + args[i] = n(bad); + is_err(ets(args), CellError::Num); + } + // More than 30% of the points missing. + is_err(ets(vec![n(30.0), range(&[1.0, 2.0, 3.0]), range(&[1.0, 2.0, 20.0])]), CellError::Num); + // Text time stamps; errors propagate. + is_err(ets(vec![n(13.0), range(&[1.0, 2.0]), rf(arr(vec![vec![nv(1.0)], vec![tv("x")]]))]), CellError::Value); + is_err(ets(vec![n(13.0), rf(arr(vec![vec![nv(1.0)], vec![Value::Error(CellError::Div0)]])), range(&[1.0, 2.0])]), CellError::Div0); + // Confidence level outside (0, 1); statistic type outside 1–8. + is_err(ev("FORECAST.ETS.CONFINT", vec![n(13.0), range(&ys), range(&ts), n(1.0)]), CellError::Num); + is_err(ev("FORECAST.ETS.CONFINT", vec![n(13.0), range(&ys), range(&ts), n(0.0)]), CellError::Num); + is_err(ev("FORECAST.ETS.STAT", vec![range(&ys), range(&ts), n(9.0)]), CellError::Num); + is_err(ev("FORECAST.ETS.STAT", vec![range(&ys), range(&ts), n(0.0)]), CellError::Num); + // Hostile spans: a huge gap is rejected before anything is allocated. + 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); + } +} diff --git a/crates/functions/src/lib.rs b/crates/functions/src/lib.rs index 75c0283..c2b904b 100644 --- a/crates/functions/src/lib.rs +++ b/crates/functions/src/lib.rs @@ -65,6 +65,7 @@ pub(crate) mod database; pub(crate) mod date; pub(crate) mod dynamic; pub(crate) mod engineering; +pub(crate) mod ets; pub(crate) mod financial; pub(crate) mod info; pub(crate) mod lift; @@ -168,6 +169,7 @@ fn registry() -> &'static (Vec, HashMap<&'static str, usize>) { for list in [ math::specs(), stat::specs(), + ets::specs(), text::specs(), date::specs(), lookup::specs(), diff --git a/crates/xlsx/src/array_formula.rs b/crates/xlsx/src/array_formula.rs index 2512cc0..5d4da2d 100644 --- a/crates/xlsx/src/array_formula.rs +++ b/crates/xlsx/src/array_formula.rs @@ -215,6 +215,9 @@ fn scalar_arg(name: &str, i: usize) -> bool { "FILTER" | "SORTBY" => i > 1, "SORT" | "UNIQUE" | "TAKE" | "DROP" | "CHOOSECOLS" | "CHOOSEROWS" | "TOROW" | "TOCOL" | "WRAPROWS" | "WRAPCOLS" | "EXPAND" => i != 0, "LINEST" | "LOGEST" | "TREND" | "GROWTH" => i > 1, + "FORECAST" | "FORECAST.LINEAR" => i == 0, + "FORECAST.ETS" | "FORECAST.ETS.CONFINT" => i != 1 && i != 2, + "FORECAST.ETS.SEASONALITY" | "FORECAST.ETS.STAT" => i > 1, _ => true, } } diff --git a/crates/xlsx/src/tables.rs b/crates/xlsx/src/tables.rs index 61f0b7c..bb0161b 100644 --- a/crates/xlsx/src/tables.rs +++ b/crates/xlsx/src/tables.rs @@ -321,6 +321,7 @@ mod tests { #[test] fn functions() { assert_eq!(file_function_name("xlookup").as_deref(), Some("_xlfn.XLOOKUP")); + assert_eq!(file_function_name("forecast.ets.confint").as_deref(), Some("_xlfn.FORECAST.ETS.CONFINT")); assert_eq!(file_function_name("FILTER").as_deref(), Some("_xlfn._xlws.FILTER")); assert_eq!(file_function_name("SUM"), None); assert_eq!(builtin_format(14), Some("m/d/yyyy")); diff --git a/crates/xlsx/src/tests/array_export.rs b/crates/xlsx/src/tests/array_export.rs index d92f473..d9ac588 100644 --- a/crates/xlsx/src/tests/array_export.rs +++ b/crates/xlsx/src/tests/array_export.rs @@ -257,6 +257,12 @@ fn implicit_intersection_metadata_respects_function_argument_context() { ("LET(x,A1:A3,SUM(x*2))", true), ("XLOOKUP(1,A1:A3,B1:B3)", false), ("XLOOKUP(1,A1:A3,B1:C3)", true), + ("FORECAST.LINEAR(4,A1:A3,B1:B3)", false), + ("FORECAST.ETS(4,A1:A3,B1:B3,1,1,1)", false), + ("FORECAST.ETS(C1:C3,A1:A3,B1:B3)", true), + ("FORECAST.ETS.CONFINT(4,A1:A3,B1:B3,0.95)", false), + ("FORECAST.ETS.SEASONALITY(A1:A3,B1:B3)", false), + ("FORECAST.ETS.STAT(A1:A3,B1:B3,1)", false), ], ); } diff --git a/docs/function-parity.md b/docs/function-parity.md index 8ff9d49..88a324d 100644 --- a/docs/function-parity.md +++ b/docs/function-parity.md @@ -1,6 +1,6 @@ # Function parity: worksheet functions and formulas -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created; measured against Microsoft's published Excel function list) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (FORECAST.ETS family added) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Excel's worksheet functions, category by category, against GridCraft's function library. Part of [target-app-parity.md](target-app-parity.md); gaps feed [gaps.md](gaps.md). @@ -12,7 +12,7 @@ Excel's worksheet functions, category by category, against GridCraft's function them), plus 14 text functions the page lists under combined entries (FIND/FINDB, LEFT/LEFTB, LEN/LENB, MID/MIDB, REPLACE/REPLACEB, RIGHT/RIGHTB, SEARCH/SEARCHB), BETA.INV, and COPILOT and PY (Microsoft 365 functions not on that page when fetched). -- **Ours (501):** every function spec in `crates/functions/src/*.rs` (`f!("NAME", …)`) plus the +- **Ours (505):** every function spec in `crates/functions/src/*.rs` (`f!("NAME", …)`) plus the evaluator's special forms in `crates/calc/src/eval.rs` (`SPECIAL_FUNCTIONS`: IF, IFS, CHOOSE, OFFSET, INDIRECT, INDEX, SUBTOTAL, AGGREGATE, TEXT, LET, LAMBDA, MAP, REDUCE, SCAN, BYROW, BYCOL, MAKEARRAY…), excluding the test-only `ADD2`. Every one is an Excel name. @@ -23,11 +23,11 @@ Excel's worksheet functions, category by category, against GridCraft's function ## Coverage -**Measured: 501 / 523 by name (95.8%); 492 / 523 working (94.1%).** +**Measured: 505 / 523 by name (96.6%); 496 / 523 working (94.8%).** | Category | Excel | Ours | Working | % working | Missing | |---|---|---|---|---|---| -| Statistical | 111 | 107 | 107 | 96% | FORECAST.ETS, FORECAST.ETS.CONFINT, FORECAST.ETS.SEASONALITY, FORECAST.ETS.STAT | +| Statistical | 111 | 111 | 111 | 100% | — | | Math and trigonometry | 80 | 79 | 79 | 99% | PERCENTOF | | Financial | 55 | 51 | 51 | 93% | ODDFPRICE, ODDFYIELD, ODDLPRICE, ODDLYIELD | | Engineering | 54 | 54 | 54 | 100% | — | @@ -42,10 +42,10 @@ Excel's worksheet functions, category by category, against GridCraft's function | Web | 3 | 3 | 1 | 33% | (WEBSERVICE, FILTERXML are stubs; ENCODEURL works) | | Add-in and Automation | 3 | 0 | 0 | 0% | CALL, EUROCONVERT, REGISTER.ID | | AI and Python | 2 | 0 | 0 | 0% | COPILOT, PY | -| **Total** | **523** | **501** | **492** | **94.1%** | 22 missing, 9 stubs | +| **Total** | **523** | **505** | **496** | **94.8%** | 18 missing, 9 stubs | By the Excel version that introduced them (working / total): before 2010 342 / 360, 2010 -56 / 56, 2013 49 / 51, 2016 1 / 5, 2019 6 / 6, 2021 11 / 11, 2024 22 / 23, Microsoft 365 5 / 11. +56 / 56, 2013 49 / 51, 2016 5 / 5, 2019 6 / 6, 2021 11 / 11, 2024 22 / 23, Microsoft 365 5 / 11. Dynamic-array and LAMBDA-era functions (FILTER, SORT, UNIQUE, XLOOKUP, VSTACK, TAKE, GROUPBY, PIVOTBY, REGEXTEST…) are all present. @@ -53,7 +53,6 @@ PIVOTBY, REGEXTEST…) are all present. | Function(s) | In scope? | Estimate | Note | |---|---|---|---| -| FORECAST.ETS family (4) | yes | 4–6 h | AAA exponential smoothing with seasonality detection; needed for Forecast Sheet too | | ODDFPRICE, ODDFYIELD, ODDLPRICE, ODDLYIELD | yes | 2–4 h | Odd first/last period bond maths | | GETPIVOTDATA | yes | 2–3 h | Needs the pivot engine's result cache; also the "Generate GetPivotData" option | | TRIMRANGE, PERCENTOF, BAHTTEXT | yes | 1–2 h | TRIMRANGE also implies the `.:` trim-ref operators | @@ -97,4 +96,5 @@ workbooks; results checked in as data, not Excel files) and fixing what it finds | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | FORECAST.ETS, .CONFINT, .SEASONALITY, .STAT added (AAA exponential smoothing; Excel's optimiser is undocumented, so last digits on noisy data may differ): 505 / 523 by name, 496 working | | 2026-10-10 | major | Created: 501 / 523 by name, 492 working, per category, against Microsoft's published list | diff --git a/docs/gaps.md b/docs/gaps.md index aa752b8..a033d0b 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created from the full re-measure against Excel for Mac 16.113.4 and the issue tracker) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (§13, §15: FORECAST.ETS family landed) · **Target:** Microsoft Excel (Microsoft 365) Every known shortfall against Excel, one entry each, ranked by user impact (what blocks someone using GridCraft for real work first). This is the work list: pick from the top, check the open @@ -98,15 +98,15 @@ 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; FORECAST.ETS family missing. - **Estimate:** 22–35 h. +- `data.solver`, `data.analysisToolPak`, `data.forecastSheet` missing (the FORECAST.ETS family the + Forecast Sheet builds on is in). **Estimate:** 18–30 h. ### 14. Page Layout view and print polish (U, F) - No paper view with rulers and in-place header/footer editing; web print exports only (#141, PR #143). **Estimate:** 12–20 h. ### 15. Remaining functions and an oracle suite (F) -- 22 missing, 9 stubs; no Excel-computed oracle corpus; #137, #138. **Estimate:** 18–30 h. +- 18 missing, 9 stubs; no Excel-computed oracle corpus; #137, #138. **Estimate:** 18–30 h. [function-parity.md](function-parity.md) ### 16. Collaboration and review (F) @@ -149,6 +149,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | §13, §15: FORECAST.ETS family landed (#314) | | 2026-10-10 | minor | Updated for the 10-10 merges (#17, #31, #38, #62, #82, #104, #116) and the new chart resize bug (#190) | | 2026-10-10 | minor | Noted that no gap blocks the alpha gate | | 2026-10-10 | major | Created: 24 ranked gaps with evidence, from the full re-measure and the 60 open issues |