diff --git a/crates/functions/src/ets.rs b/crates/functions/src/ets.rs index 617b171ac..90afb4382 100644 --- a/crates/functions/src/ets.rs +++ b/crates/functions/src/ets.rs @@ -374,14 +374,21 @@ pub(crate) fn series(values: &Value, timeline: &Value, opts: &Options, sys: Date /// value at either end). fn interpolate(slots: &[Option]) -> Vec { let mut out = Vec::with_capacity(slots.len()); + // Keep the next known point until it is reached, scanning each slot only once for lookahead. + let mut known = slots.iter().enumerate().filter_map(|(j, s)| { + #[cfg(test)] + tests::INTERPOLATION_VISITS.with(|visits| visits.set(visits.get() + 1)); + s.map(|v| (j, v)) + }); + let mut next = known.next(); 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)); + next = known.next(); 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, @@ -925,6 +932,72 @@ mod tests { use super::*; use crate::util::testutil::*; + thread_local! { + pub(super) static INTERPOLATION_VISITS: std::cell::Cell = const { std::cell::Cell::new(0) }; + } + + #[test] + fn interpolation_preserves_gaps_and_endpoint_values() { + for (slots, expected) in [ + ( + vec![None, Some(4.0), None, None, Some(13.0), Some(-2.0), None, Some(8.0), None, None], + vec![4.0, 4.0, 7.0, 10.0, 13.0, -2.0, 3.0, 8.0, 8.0, 8.0], + ), + (vec![Some(-7.0), None, Some(4.0)], vec![-7.0, -1.5, 4.0]), + (vec![Some(3.0), Some(-7.0), Some(2.0)], vec![3.0, -7.0, 2.0]), + (vec![None, None, None], vec![0.0, 0.0, 0.0]), + (vec![Some(6.0)], vec![6.0]), + (vec![], vec![]), + ] { + assert_eq!(interpolate(&slots), expected, "{slots:?}"); + } + } + + #[test] + fn interpolation_lookahead_work_is_linear() { + let mut measured = Vec::new(); + for len in [2_000, 4_000] { + let missing = len * 3 / 10; + let gap = (len - missing) / 2..(len + missing) / 2; + let expected: Vec = (0..len).map(|i| (3 * i + 5) as f64).collect(); + let slots: Vec> = expected.iter().enumerate().map(|(i, &v)| (!gap.contains(&i)).then_some(v)).collect(); + INTERPOLATION_VISITS.with(|visits| visits.set(0)); + let actual = interpolate(&slots); + let visits = INTERPOLATION_VISITS.with(|visits| visits.get()); + assert_eq!(actual, expected); + measured.push((len, visits)); + } + // Record both sizes before asserting the work bound, including on the old algorithm. + eprintln!("interpolation lookahead visits (slots, visits): {measured:?}"); + for (len, visits) in measured { + assert!(visits > 0 && visits <= 2 * len, "{len} slots required {visits} lookahead visits"); + } + } + + #[test] + fn series_completes_a_long_gap_at_the_missing_point_limit() { + let len = 2_000; + let gap = 700..1_300; // Exactly 30% of the completed timeline. + let mut times: Vec = (0..len).filter(|i| !gap.contains(i)).map(|i| (i + 1) as f64).collect(); + let mut values: Vec = times.iter().map(|t| 3.0 * t + 2.0).collect(); + let options = Options::default(); + let completed = series(&col(&values), &col(×), &options, DateSystem::D1900).unwrap(); + let expected: Vec = (0..len).map(|i| (3 * i + 5) as f64).collect(); + assert_eq!(completed.y, expected); + assert_eq!(completed.step, 1.0); + assert_eq!(completed.horizon((len + 1) as f64), Ok(1.0)); + + let zeroes = series(&col(&values), &col(×), &Options { interpolate: false, ..options }, DateSystem::D1900).unwrap(); + for (i, &value) in zeroes.y.iter().enumerate() { + assert_eq!(value, if gap.contains(&i) { 0.0 } else { expected[i] }); + } + + // Remove the first known point after the gap without changing either timeline endpoint. + times.remove(gap.start); + values.remove(gap.start); + assert!(matches!(series(&col(&values), &col(×), &options, DateSystem::D1900), Err(CellError::Num))); + } + fn range(xs: &[f64]) -> Arg { rf(col(xs)) } diff --git a/docs/function-parity.md b/docs/function-parity.md index 274800699..d90c9800b 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-11 · **Last updated:** 2026-10-13 · **Change:** minor (clearing large sparse ranges recalculates every changed cell's dependents, #1032) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-16 · **Change:** minor (FORECAST.ETS missing-point interpolation scans the timeline in linear work, #1042) · **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). @@ -55,6 +55,11 @@ By the Excel version that introduced them (working / total): before 2010 346 / 3 Dynamic-array and LAMBDA-era functions (FILTER, SORT, UNIQUE, XLOOKUP, VSTACK, TAKE, GROUPBY, PIVOTBY, REGEXTEST…) are all present. +FORECAST.ETS missing-point interpolation walks known points once, so filling a long gap takes +linear work in the completed timeline length (#1042). Interpolation values, nearest-value +endpoint handling, zero-fill mode and the existing 30% missing-point limit are unchanged. +This improves series preparation; it does not change the model-fitting or seasonality algorithms. + ### Scope of what's missing | Function(s) | In scope? | Estimate | Note | @@ -109,6 +114,7 @@ workbooks; results checked in as data, not Excel files) and fixing what it finds | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | FORECAST.ETS gap interpolation uses a forward-only known-point scan instead of rescanning each gap for every missing value; numerical behavior and missing-point limits are preserved (#1042) | | 2026-10-13 | minor | Clear reports changed stored cells sparsely, including those beyond the first million coordinates; dependent formulas and removed spills update, while clearing empty merges still unblocks spills (#1032) | | 2026-10-12 | minor | USDOLLAR added as a Compatibility function (not on Microsoft's list, so the counts don't change); MIRR with no positive flow is -1, COUNTIF's bare `"<="` / `">="` skip empty strings, INDIRECT with `a1` = FALSE gives `#REF!` for A1-style text (#738) | | 2026-10-11 | minor | TEXT / number formats fixed against Excel-cached results: fraction literals and zero fractions, `0` denominators zero padded, `?` pads the thousands separator, booleans use the text section (226 mismatches → 0 in 352 TEXT cells) |