mirror of
https://github.com/storytold/gridcraft.git
synced 2026-11-13 22:02:24 +00:00
fix(ets): avoid repeated interpolation scans (#1042)
This commit is contained in:
1 parent
f1894c6da6
commit
1131de97ff
2 files changed
+81
-2
No files matched your search
@@ -374,14 +374,21 @@ pub(crate) fn series(values: &Value, timeline: &Value, opts: &Options, sys: Date
|
||||
/// value at either end).
|
||||
fn interpolate(slots: &[Option<f64>]) -> Vec<f64> {
|
||||
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<usize> = 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<f64> = (0..len).map(|i| (3 * i + 5) as f64).collect();
|
||||
let slots: Vec<Option<f64>> = 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<f64> = (0..len).filter(|i| !gap.contains(i)).map(|i| (i + 1) as f64).collect();
|
||||
let mut values: Vec<f64> = 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<f64> = (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))
|
||||
}
|
||||
|
||||
@@ -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) |
|
||||
|
||||
Reference in new issue
Block a user