Skip to main content

ravenrs_sheet/functions/
ets.rs

1//! The FORECAST.ETS family: additive-error, additive-trend, additive-seasonality (AAA)
2//! exponential smoothing over an evenly spaced timeline.
3//!
4//! Model (Hyndman, Koehler, Ord & Snyder, "Forecasting with Exponential Smoothing: The State
5//! Space Approach", 2008, ETS(A,A,A), component form):
6//!
7//! ```text
8//! ŷ_t = l_{t-1} + b_{t-1} + s_{t-m}        e_t = y_t − ŷ_t
9//! l_t = l_{t-1} + b_{t-1} + α·e_t
10//! b_t = b_{t-1} + α·β·e_t
11//! s_t = s_{t-m} + γ·e_t
12//! ```
13//!
14//! Initial states follow the book's heuristic (§2.6.1): seasonal indices from a classical
15//! decomposition (centred moving average) of the first seasons, level and trend from a straight
16//! line fitted to the first ten seasonally adjusted values. α, β and γ (each in [0, 1]) minimise
17//! the sum of squared one-step errors: a coarse grid, then a Nelder–Mead simplex clamped to the
18//! box. Excel does not document its optimiser, so fitted parameters (and the last digits of
19//! forecasts on noisy data) can differ from Excel's; exact data (straight lines, exactly
20//! repeating seasons) forecast exactly either way.
21//!
22//! Seasonality detection: the autocorrelation of the linearly detrended series; the highest local
23//! peak above the 95% white-noise bound (2/√n) at a lag of at most n/2 (and at most 8,760).
24//!
25//! Prediction intervals use the model's h-step forecast variance (the book's Table 6.1, class 1):
26//! `σ²·(1 + Σ_{j=1}^{h−1} (α + αβ·j + γ·[j mod m = 0])²)`, σ² = SSE / n, with the normal quantile.
27
28use crate::base::date::{datetime_from_serial, days_in_month_in, serial_from_ymd};
29use crate::base::{CellError, DateSystem, Value};
30
31use crate::functions::special::norm_inv;
32use crate::functions::util::{R, arg, as_array, has, num, num_val};
33use crate::functions::{Arg, Ctx, FnSpec};
34
35type C<'a> = &'a mut dyn Ctx;
36
37/// Longest timeline (after completing missing points) the functions accept.
38pub(crate) const MAX_POINTS: usize = 200_000;
39/// Longest seasonality Excel supports (hours in a year).
40pub(crate) const MAX_SEASON: usize = 8_760;
41/// Share of the timeline's points that may be missing.
42const MAX_MISSING: f64 = 0.3;
43/// Seasonally adjusted values the initial level and trend are fitted to.
44const INIT_POINTS: usize = 10;
45const NM_ITERATIONS: usize = 400;
46
47// ---------------------------------------------------------------------------------------------
48// Options
49// ---------------------------------------------------------------------------------------------
50
51#[derive(Clone, Copy, Debug, PartialEq, Eq)]
52pub(crate) enum Season {
53    Detect,
54    None,
55    Fixed(usize),
56}
57
58/// Duplicate time stamps are combined with this function.
59#[derive(Clone, Copy, Debug, PartialEq, Eq)]
60pub(crate) enum Aggregate {
61    Average,
62    Count,
63    CountA,
64    Max,
65    Median,
66    Min,
67    Sum,
68}
69
70#[derive(Clone, Copy, Debug)]
71pub(crate) struct Options {
72    pub seasonality: Season,
73    /// `true`: missing points are interpolated from their neighbours; `false`: they are zero.
74    pub interpolate: bool,
75    pub aggregate: Aggregate,
76}
77
78impl Default for Options {
79    fn default() -> Self {
80        Options { seasonality: Season::Detect, interpolate: true, aggregate: Aggregate::Average }
81    }
82}
83
84fn whole(a: &[Arg], i: usize) -> R<Option<f64>> {
85    if !has(a, i) {
86        return Ok(None);
87    }
88    let v = num(a, i)?;
89    if !v.is_finite() {
90        return Err(CellError::Num);
91    }
92    Ok(Some(v.trunc()))
93}
94
95/// `[seasonality]`: 1 (default) detects, 0 means none, 2–8,760 is the season length.
96pub(crate) fn season_arg(a: &[Arg], i: usize) -> R<Season> {
97    match whole(a, i)? {
98        None => Ok(Season::Detect),
99        Some(1.0) => Ok(Season::Detect),
100        Some(0.0) => Ok(Season::None),
101        Some(v) if v >= 2.0 && v <= MAX_SEASON as f64 => Ok(Season::Fixed(v as usize)),
102        Some(_) => Err(CellError::Num),
103    }
104}
105
106/// `[data_completion]`: 1 (default) interpolates, 0 fills zeros.
107fn completion_arg(a: &[Arg], i: usize) -> R<bool> {
108    match whole(a, i)? {
109        None => Ok(true),
110        Some(1.0) => Ok(true),
111        Some(0.0) => Ok(false),
112        Some(_) => Err(CellError::Num),
113    }
114}
115
116/// `[aggregation]`: 0 or 1 AVERAGE (default), 2 COUNT, 3 COUNTA, 4 MAX, 5 MEDIAN, 6 MIN, 7 SUM.
117fn aggregate_arg(a: &[Arg], i: usize) -> R<Aggregate> {
118    Ok(match whole(a, i)? {
119        None => Aggregate::Average,
120        Some(v) => match v as i64 {
121            0 | 1 => Aggregate::Average,
122            2 => Aggregate::Count,
123            3 => Aggregate::CountA,
124            4 => Aggregate::Max,
125            5 => Aggregate::Median,
126            6 => Aggregate::Min,
127            7 => Aggregate::Sum,
128            _ => return Err(CellError::Num),
129        },
130    })
131}
132
133// ---------------------------------------------------------------------------------------------
134// Timeline
135// ---------------------------------------------------------------------------------------------
136
137/// How timeline values map to point positions (0 = first point).
138#[derive(Clone, Copy, Debug)]
139enum Axis {
140    /// A constant numeric step.
141    Linear { t0: f64, step: f64 },
142    /// Whole-day dates a constant number of calendar months apart, all on the same day of the
143    /// month (`day = Some(d)`) or all on the last day of their month (`day = None`).
144    Months { sys: DateSystem, month0: i64, step: i64, day: Option<u32> },
145}
146
147fn month_parts(sys: DateSystem, t: f64) -> Option<(i64, f64)> {
148    let d = datetime_from_serial(sys, t.floor())?;
149    if d.day == 0 {
150        return None;
151    }
152    Some((i64::from(d.year) * 12 + i64::from(d.month) - 1, f64::from(d.day)))
153}
154
155impl Axis {
156    fn position(&self, t: f64) -> Option<f64> {
157        match *self {
158            Axis::Linear { t0, step } => Some((t - t0) / step),
159            Axis::Months { sys, month0, step, day } => {
160                let d = datetime_from_serial(sys, t.floor())?;
161                if d.day == 0 {
162                    return None;
163                }
164                let month = i64::from(d.year) * 12 + i64::from(d.month) - 1;
165                let dim = f64::from(days_in_month_in(sys, d.year, d.month));
166                if dim <= 0.0 {
167                    return None;
168                }
169                let anchor = day.map_or(dim, |a| f64::from(a).min(dim));
170                let offset = (f64::from(d.day) - anchor + (t - t.floor())) / dim;
171                Some(((month - month0) as f64 + offset) / step as f64)
172            }
173        }
174    }
175}
176
177fn gcd(mut a: i64, mut b: i64) -> i64 {
178    while b != 0 {
179        let r = a % b;
180        a = b;
181        b = r;
182    }
183    a.abs()
184}
185
186/// The axis of sorted, distinct time stamps: a constant step (missing points allowed) or, for
187/// dates, a constant number of calendar months.
188fn detect_axis(times: &[f64], sys: DateSystem) -> R<Axis> {
189    let (Some(&t0), Some(&t1)) = (times.first(), times.get(1)) else { return Err(CellError::Num) };
190    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);
191    if step.is_finite() && step > 0.0 {
192        let even = times.windows(2).all(|w| match (w.first(), w.get(1)) {
193            (Some(a), Some(b)) => {
194                let r = (b - a) / step;
195                (r - r.round()).abs() <= 1e-6
196            }
197            _ => true,
198        });
199        if even {
200            return Ok(Axis::Linear { t0, step });
201        }
202    }
203    // Calendar months: whole days on one day of the month (or all month ends).
204    let mut months = Vec::with_capacity(times.len());
205    let mut same_day: Option<u32> = None;
206    let mut all_same = true;
207    let mut all_end = true;
208    for &t in times {
209        if t.fract() != 0.0 {
210            return Err(CellError::Num);
211        }
212        let d = datetime_from_serial(sys, t).ok_or(CellError::Num)?;
213        if d.day == 0 {
214            return Err(CellError::Num);
215        }
216        months.push(i64::from(d.year) * 12 + i64::from(d.month) - 1);
217        match same_day {
218            None => same_day = Some(d.day),
219            Some(x) if x != d.day => all_same = false,
220            _ => {}
221        }
222        if d.day != days_in_month_in(sys, d.year, d.month) {
223            all_end = false;
224        }
225    }
226    let day = if all_same {
227        same_day
228    } else if all_end {
229        None
230    } else {
231        return Err(CellError::Num);
232    };
233    let mut g = 0i64;
234    for w in months.windows(2) {
235        if let (Some(a), Some(b)) = (w.first(), w.get(1)) {
236            let d = b - a;
237            if d <= 0 {
238                return Err(CellError::Num);
239            }
240            g = gcd(g, d);
241        }
242    }
243    let month0 = month_parts(sys, t0).ok_or(CellError::Num)?.0;
244    if g <= 0 {
245        return Err(CellError::Num);
246    }
247    Ok(Axis::Months { sys, month0, step: g, day })
248}
249
250/// An evenly spaced series built from a values/timeline pair.
251#[derive(Clone, Debug)]
252pub(crate) struct Series {
253    pub y: Vec<f64>,
254    axis: Axis,
255    /// The timeline's step in timeline units (the average step for calendar months).
256    pub step: f64,
257}
258
259impl Series {
260    /// Position of a time stamp relative to the last point (0 = last point, 1 = next step).
261    pub fn horizon(&self, t: f64) -> R<f64> {
262        let p = self.axis.position(t).ok_or(CellError::Num)?;
263        let h = p - (self.y.len() as f64 - 1.0);
264        if !h.is_finite() || h < -1e-9 {
265            return Err(CellError::Num);
266        }
267        Ok(h.max(0.0))
268    }
269}
270
271fn aggregate(kind: Aggregate, xs: &mut [f64]) -> Option<f64> {
272    let n = xs.len() as f64;
273    match kind {
274        Aggregate::Count | Aggregate::CountA => Some(n),
275        _ if xs.is_empty() => None,
276        Aggregate::Average => Some(xs.iter().sum::<f64>() / n),
277        Aggregate::Sum => Some(xs.iter().sum()),
278        Aggregate::Max => xs.iter().copied().reduce(f64::max),
279        Aggregate::Min => xs.iter().copied().reduce(f64::min),
280        Aggregate::Median => {
281            xs.sort_by(f64::total_cmp);
282            let k = xs.len() / 2;
283            let hi = xs.get(k).copied()?;
284            if xs.len() % 2 == 1 { Some(hi) } else { Some((xs.get(k.checked_sub(1)?).copied()? + hi) / 2.0) }
285        }
286    }
287}
288
289/// Builds the series: pairs values with time stamps, sorts, aggregates duplicates, checks the
290/// step and completes missing points.
291pub(crate) fn series(values: &Value, timeline: &Value, opts: &Options, sys: DateSystem) -> R<Series> {
292    let va = as_array(values);
293    let ta = as_array(timeline);
294    if va.data.len() != ta.data.len() {
295        return Err(CellError::NA);
296    }
297    if va.data.len() > MAX_POINTS {
298        return Err(CellError::Num);
299    }
300    let mut points: Vec<(f64, Option<f64>)> = Vec::with_capacity(va.data.len());
301    for (t, v) in ta.data.iter().zip(va.data.iter()) {
302        if let Value::Error(e) = t {
303            return Err(*e);
304        }
305        if let Value::Error(e) = v {
306            return Err(*e);
307        }
308        let value = match v {
309            Value::Number(x) => Some(*x),
310            Value::Empty => None,
311            _ => return Err(CellError::Value),
312        };
313        let time = match t {
314            Value::Number(x) if x.is_finite() => *x,
315            // A blank row (no time stamp, no value) is ignored.
316            Value::Empty if value.is_none() => continue,
317            _ => return Err(CellError::Value),
318        };
319        points.push((time, value));
320    }
321    points.sort_by(|a, b| a.0.total_cmp(&b.0));
322    let mut times: Vec<f64> = points.iter().map(|p| p.0).collect();
323    times.dedup();
324    if times.len() < 2 {
325        return Err(CellError::Num);
326    }
327    let axis = detect_axis(&times, sys)?;
328    let (Some(&first), Some(&last)) = (times.first(), times.last()) else { return Err(CellError::Num) };
329    let span = axis.position(last).ok_or(CellError::Num)?.round();
330    if !(span >= 1.0 && span < MAX_POINTS as f64) {
331        return Err(CellError::Num);
332    }
333    let len = span as usize + 1;
334    // Group the (sorted) points by position.
335    let mut slots: Vec<Option<f64>> = vec![None; len];
336    let mut group: Vec<f64> = Vec::new();
337    let mut group_at: Option<usize> = None;
338    let flush = |at: Option<usize>, group: &mut Vec<f64>, slots: &mut Vec<Option<f64>>| {
339        if let Some(i) = at
340            && let Some(s) = slots.get_mut(i)
341        {
342            *s = aggregate(opts.aggregate, group);
343        }
344        group.clear();
345    };
346    for &(t, v) in &points {
347        let p = axis.position(t).ok_or(CellError::Num)?.round();
348        if !(p >= 0.0 && p < len as f64) {
349            return Err(CellError::Num);
350        }
351        let p = p as usize;
352        if group_at != Some(p) {
353            flush(group_at, &mut group, &mut slots);
354            group_at = Some(p);
355        }
356        if let Some(x) = v {
357            group.push(x);
358        }
359    }
360    flush(group_at, &mut group, &mut slots);
361    let known = slots.iter().filter(|s| s.is_some()).count();
362    if known == 0 || (len - known) as f64 > MAX_MISSING * len as f64 {
363        return Err(CellError::Num);
364    }
365    let y = if opts.interpolate { interpolate(&slots) } else { slots.iter().map(|s| s.unwrap_or(0.0)).collect() };
366    let step = match axis {
367        Axis::Linear { step, .. } => step,
368        Axis::Months { .. } => (last - first) / span,
369    };
370    Ok(Series { y, axis, step })
371}
372
373/// Missing points become the straight line between their known neighbours (the nearest known
374/// value at either end).
375fn interpolate(slots: &[Option<f64>]) -> Vec<f64> {
376    let mut out = Vec::with_capacity(slots.len());
377    // Keep the next known point until it is reached, scanning each slot only once for lookahead.
378    let mut known = slots.iter().enumerate().filter_map(|(j, s)| {
379        #[cfg(test)]
380        tests::INTERPOLATION_VISITS.with(|visits| visits.set(visits.get() + 1));
381        s.map(|v| (j, v))
382    });
383    let mut next = known.next();
384    let mut prev: Option<(usize, f64)> = None;
385    for (i, s) in slots.iter().enumerate() {
386        if let Some(v) = s {
387            out.push(*v);
388            prev = Some((i, *v));
389            next = known.next();
390            continue;
391        }
392        out.push(match (prev, next) {
393            (Some((a, va)), Some((b, vb))) => va + (vb - va) * (i - a) as f64 / (b - a) as f64,
394            (Some((_, v)), None) | (None, Some((_, v))) => v,
395            (None, None) => 0.0,
396        });
397    }
398    out
399}
400
401/// A timeline's step, for continuing it past its last point (the Forecast Sheet's future rows).
402#[derive(Clone, Copy, Debug)]
403pub struct Timeline {
404    axis: Axis,
405    /// Position of the last time stamp.
406    last: i64,
407}
408
409impl Timeline {
410    /// The step of a set of time stamps (any order, duplicates allowed): a constant numeric step
411    /// (missing points allowed) or, for whole-day dates on one day of the month, calendar months.
412    /// `None` without at least two distinct, finite time stamps or without a constant step.
413    pub fn detect(times: &[f64], sys: DateSystem) -> Option<Timeline> {
414        if times.iter().any(|t| !t.is_finite()) {
415            return None;
416        }
417        let mut sorted = times.to_vec();
418        sorted.sort_by(f64::total_cmp);
419        sorted.dedup();
420        let axis = detect_axis(&sorted, sys).ok()?;
421        let last = axis.position(*sorted.last()?)?.round();
422        // Like the functions, at most 30% of the points may be missing.
423        if !(0.0..MAX_POINTS as f64).contains(&last) || (last + 1.0 - sorted.len() as f64) > MAX_MISSING * (last + 1.0) {
424            return None;
425        }
426        Some(Timeline { axis, last: last as i64 })
427    }
428
429    /// Steps from the last time stamp to `t` (fractional between steps, negative before it).
430    pub fn steps_after_last(&self, t: f64) -> Option<f64> {
431        let p = self.axis.position(t)? - self.last as f64;
432        p.is_finite().then_some(p)
433    }
434
435    /// The time stamp `k` steps after the last one.
436    pub fn after_last(&self, k: u32) -> Option<f64> {
437        let pos = self.last.checked_add(i64::from(k))?;
438        match self.axis {
439            Axis::Linear { t0, step } => {
440                let t = t0 + pos as f64 * step;
441                t.is_finite().then_some(t)
442            }
443            Axis::Months { sys, month0, step, day } => {
444                let month = month0.checked_add(pos.checked_mul(step)?)?;
445                let (y, m) = (month.div_euclid(12), month.rem_euclid(12) + 1);
446                let dim = i64::from(days_in_month_in(sys, i32::try_from(y).ok()?, u32::try_from(m).ok()?));
447                let d = day.map_or(dim, |d| i64::from(d).min(dim));
448                if y < 1900 {
449                    return None;
450                }
451                serial_from_ymd(sys, y, m, d)
452            }
453        }
454    }
455}
456
457// ---------------------------------------------------------------------------------------------
458// Seasonality detection
459// ---------------------------------------------------------------------------------------------
460
461/// Residuals of the least-squares line through `y` (x = 0, 1, 2…).
462fn detrend(y: &[f64]) -> Vec<f64> {
463    let (a, b) = line(y.iter().enumerate().map(|(i, v)| (i as f64, *v)));
464    y.iter().enumerate().map(|(i, v)| v - (a + b * i as f64)).collect()
465}
466
467/// (intercept, slope) of the least-squares line through the points (slope 0 for one point).
468fn line(points: impl Iterator<Item = (f64, f64)> + Clone) -> (f64, f64) {
469    let (mut n, mut sx, mut sy) = (0.0, 0.0, 0.0);
470    for (x, y) in points.clone() {
471        n += 1.0;
472        sx += x;
473        sy += y;
474    }
475    if n == 0.0 {
476        return (0.0, 0.0);
477    }
478    let (mx, my) = (sx / n, sy / n);
479    let (mut sxy, mut sxx) = (0.0, 0.0);
480    for (x, y) in points {
481        sxy += (x - mx) * (y - my);
482        sxx += (x - mx) * (x - mx);
483    }
484    let b = if sxx > 0.0 { sxy / sxx } else { 0.0 };
485    (my - b * mx, b)
486}
487
488/// The detected season length, 0 when the series shows none.
489pub(crate) fn detect_season(y: &[f64]) -> usize {
490    let n = y.len();
491    if n < 4 {
492        return 0;
493    }
494    let r = detrend(y);
495    let var: f64 = r.iter().map(|v| v * v).sum();
496    let scale: f64 = y.iter().map(|v| v * v).sum::<f64>();
497    if !(var.is_finite() && var > 1e-20 * scale.max(f64::MIN_POSITIVE)) {
498        return 0;
499    }
500    let max_lag = (n / 2).min(MAX_SEASON);
501    let acf: Vec<f64> = (0..=max_lag.saturating_add(1).min(n.saturating_sub(1)))
502        .map(|k| r.iter().skip(k).zip(r.iter()).map(|(a, b)| a * b).sum::<f64>() / var)
503        .collect();
504    let bound = 2.0 / (n as f64).sqrt();
505    let mut best = 0usize;
506    let mut best_r = bound;
507    for k in 2..=max_lag {
508        let (Some(&prev), Some(&here)) = (acf.get(k - 1), acf.get(k)) else { break };
509        let next = acf.get(k + 1).copied().unwrap_or(f64::NEG_INFINITY);
510        if here > prev && here >= next && here > best_r {
511            best = k;
512            best_r = here;
513        }
514    }
515    best
516}
517
518// ---------------------------------------------------------------------------------------------
519// Model
520// ---------------------------------------------------------------------------------------------
521
522#[derive(Clone, Debug)]
523struct Init {
524    level: f64,
525    trend: f64,
526    /// Seasonal state for positions 0..m (empty without seasonality).
527    season: Vec<f64>,
528}
529
530/// Initial states (Hyndman et al. 2008, §2.6.1).
531fn initial_states(y: &[f64], m: usize) -> Init {
532    let n = y.len();
533    let mut season = vec![0.0; m];
534    if m >= 2 {
535        if n >= 2 * m {
536            // Classical decomposition of the first seasons: centred moving average as the trend.
537            let len = n.min(4 * m).max(2 * m);
538            let w = y.get(..len).unwrap_or(y);
539            let mut prefix = Vec::with_capacity(w.len() + 1);
540            prefix.push(0.0);
541            let mut acc = 0.0;
542            for v in w {
543                acc += v;
544                prefix.push(acc);
545            }
546            let sum = |a: usize, b: usize| prefix.get(b).copied().unwrap_or(0.0) - prefix.get(a).copied().unwrap_or(0.0);
547            let h = m / 2;
548            let mut totals = vec![0.0; m];
549            let mut counts = vec![0usize; m];
550            for t in h..w.len().saturating_sub(h) {
551                let ma = if m % 2 == 1 {
552                    sum(t - h, t + h + 1) / m as f64
553                } else {
554                    let ends = w.get(t - h).copied().unwrap_or(0.0) + w.get(t + h).copied().unwrap_or(0.0);
555                    (sum(t - h + 1, t + h) + ends / 2.0) / m as f64
556                };
557                if let (Some(tot), Some(c), Some(v)) = (totals.get_mut(t % m), counts.get_mut(t % m), w.get(t)) {
558                    *tot += v - ma;
559                    *c += 1;
560                }
561            }
562            for ((s, tot), c) in season.iter_mut().zip(&totals).zip(&counts) {
563                *s = if *c > 0 { tot / *c as f64 } else { 0.0 };
564            }
565        } else {
566            // Under two seasons: deviations from a straight line through the data.
567            let r = detrend(y);
568            let mut counts = vec![0usize; m];
569            for (t, v) in r.iter().enumerate() {
570                if let (Some(s), Some(c)) = (season.get_mut(t % m), counts.get_mut(t % m)) {
571                    *s += v;
572                    *c += 1;
573                }
574            }
575            for (s, c) in season.iter_mut().zip(&counts) {
576                if *c > 0 {
577                    *s /= *c as f64;
578                }
579            }
580        }
581        let mean = season.iter().sum::<f64>() / m as f64;
582        for s in &mut season {
583            *s -= mean;
584        }
585    }
586    let k = n.min(INIT_POINTS);
587    let adjusted = y.iter().take(k).enumerate().map(|(t, v)| {
588        let s = if m >= 2 { season.get(t % m).copied().unwrap_or(0.0) } else { 0.0 };
589        ((t + 1) as f64, v - s)
590    });
591    let (level, trend) = line(adjusted);
592    Init { level, trend, season }
593}
594
595/// Smoothing parameters (α, β, γ).
596type Params = [f64; 3];
597
598#[derive(Clone, Debug)]
599struct State {
600    level: f64,
601    trend: f64,
602    season: Vec<f64>,
603    sse: f64,
604}
605
606/// Runs the filter over the data; `errors` collects the one-step errors when given.
607fn filter(y: &[f64], init: &Init, p: Params, mut errors: Option<&mut Vec<f64>>) -> State {
608    let [alpha, beta, gamma] = p;
609    let m = init.season.len();
610    let (mut l, mut b) = (init.level, init.trend);
611    let mut season = init.season.clone();
612    let mut sse = 0.0;
613    for (t, v) in y.iter().enumerate() {
614        let slot = if m > 0 { season.get_mut(t % m) } else { None };
615        let s = slot.as_ref().map_or(0.0, |s| **s);
616        let e = v - (l + b + s);
617        if let Some(slot) = slot {
618            *slot = s + gamma * e;
619        }
620        l = l + b + alpha * e;
621        b += alpha * beta * e;
622        sse += e * e;
623        if let Some(errs) = errors.as_deref_mut() {
624            errs.push(e);
625        }
626    }
627    State { level: l, trend: b, season, sse }
628}
629
630fn objective(y: &[f64], init: &Init, p: Params) -> f64 {
631    let sse = filter(y, init, p, None).sse;
632    if sse.is_finite() { sse } else { f64::INFINITY }
633}
634
635fn clamp(p: Params, active: usize) -> Params {
636    let mut q = [0.0; 3];
637    for (i, (o, v)) in q.iter_mut().zip(p).enumerate() {
638        *o = if i < active { v.clamp(0.0, 1.0) } else { 0.0 };
639    }
640    q
641}
642
643/// Minimises the SSE over the active parameters (α, β and, with seasonality, γ) in [0, 1].
644fn optimise(y: &[f64], init: &Init) -> Params {
645    let active = if init.season.is_empty() { 2 } else { 3 };
646    let f = |p: Params| objective(y, init, clamp(p, active));
647    // Coarse grid.
648    const GRID: [f64; 5] = [0.05, 0.25, 0.5, 0.75, 0.95];
649    let mut best: (Params, f64) = ([0.5, 0.1, 0.1], f64::INFINITY);
650    for a in GRID {
651        for b in GRID {
652            for g in if active == 3 { &GRID[..] } else { &GRID[..1] } {
653                let p = clamp([a, b, *g], active);
654                let v = f(p);
655                if v < best.1 {
656                    best = (p, v);
657                }
658            }
659        }
660    }
661    // Nelder–Mead from the best grid point, clamped to the box.
662    let mut simplex: Vec<(Params, f64)> = vec![best];
663    for i in 0..active {
664        let mut p = best.0;
665        if let Some(x) = p.get_mut(i) {
666            *x = if *x > 0.5 { *x - 0.1 } else { *x + 0.1 };
667        }
668        let p = clamp(p, active);
669        simplex.push((p, f(p)));
670    }
671    let lerp = |a: Params, b: Params, t: f64| -> Params {
672        let mut o = [0.0; 3];
673        for ((o, x), y) in o.iter_mut().zip(a).zip(b) {
674            *o = x + t * (y - x);
675        }
676        clamp(o, active)
677    };
678    for _ in 0..NM_ITERATIONS {
679        simplex.sort_by(|a, b| a.1.total_cmp(&b.1));
680        let (Some(&lo), Some(&hi)) = (simplex.first(), simplex.last()) else { break };
681        let second = simplex.len().checked_sub(2).and_then(|i| simplex.get(i)).map_or(hi.1, |s| s.1);
682        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);
683        if (hi.1 - lo.1).abs() <= 1e-12 * lo.1.abs() + 1e-300 || size < 1e-7 {
684            break;
685        }
686        let k = simplex.len().saturating_sub(1).max(1) as f64;
687        let mut c = [0.0; 3];
688        for s in simplex.iter().take(simplex.len().saturating_sub(1)) {
689            for (ci, x) in c.iter_mut().zip(s.0) {
690                *ci += x / k;
691            }
692        }
693        let reflected = lerp(c, hi.0, -1.0);
694        let fr = f(reflected);
695        let replace = if fr < lo.1 {
696            let expanded = lerp(c, hi.0, -2.0);
697            let fe = f(expanded);
698            Some(if fe < fr { (expanded, fe) } else { (reflected, fr) })
699        } else if fr < second {
700            Some((reflected, fr))
701        } else {
702            let contracted = lerp(c, hi.0, 0.5);
703            let fc = f(contracted);
704            if fc < hi.1 { Some((contracted, fc)) } else { None }
705        };
706        match replace {
707            Some(v) => {
708                if let Some(last) = simplex.last_mut() {
709                    *last = v;
710                }
711            }
712            None => {
713                for s in simplex.iter_mut().skip(1) {
714                    let p = lerp(lo.0, s.0, 0.5);
715                    *s = (p, f(p));
716                }
717            }
718        }
719    }
720    simplex.sort_by(|a, b| a.1.total_cmp(&b.1));
721    clamp(simplex.first().map_or(best.0, |s| s.0), active)
722}
723
724/// A fitted model.
725#[derive(Clone, Debug)]
726pub(crate) struct Fit {
727    pub alpha: f64,
728    pub beta: f64,
729    pub gamma: f64,
730    pub m: usize,
731    n: usize,
732    state: State,
733    pub errors: Vec<f64>,
734}
735
736pub(crate) fn fit(y: &[f64], m: usize) -> Fit {
737    let m = if m >= 2 { m } else { 0 };
738    let init = initial_states(y, m);
739    let p = optimise(y, &init);
740    let mut errors = Vec::with_capacity(y.len());
741    let state = filter(y, &init, p, Some(&mut errors));
742    let [alpha, beta, gamma] = p;
743    Fit { alpha, beta, gamma, m, n: y.len(), state, errors }
744}
745
746impl Fit {
747    fn at(&self, k: f64) -> f64 {
748        let s = if self.m > 0 {
749            let idx = ((self.n as f64 - 1.0) + k).rem_euclid(self.m as f64);
750            self.state.season.get(idx as usize).copied().unwrap_or(0.0)
751        } else {
752            0.0
753        };
754        self.state.level + k * self.state.trend + s
755    }
756
757    /// Point forecast `h` steps after the last point (fractional steps interpolate).
758    pub fn forecast(&self, h: f64) -> f64 {
759        let lo = h.floor();
760        let a = self.at(lo);
761        if h == lo { a } else { a + (h - lo) * (self.at(lo + 1.0) - a) }
762    }
763
764    /// Standard deviation of the forecast error `h` steps ahead.
765    pub fn forecast_sd(&self, h: f64) -> f64 {
766        let sigma2 = self.state.sse / self.n.max(1) as f64;
767        let k = (h.ceil().max(1.0)) - 1.0;
768        let (a, b, g) = (self.alpha, self.alpha * self.beta, self.gamma);
769        // Σ_{j=1}^{k} (a + b·j)² + seasonal terms, in closed form.
770        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;
771        if self.m > 0 {
772            let m = self.m as f64;
773            let q = (k / m).floor();
774            sum += 2.0 * g * (q * a + b * m * q * (q + 1.0) / 2.0) + g * g * q;
775        }
776        (sigma2 * (1.0 + sum)).max(0.0).sqrt()
777    }
778}
779
780// ---------------------------------------------------------------------------------------------
781// Worksheet functions
782// ---------------------------------------------------------------------------------------------
783
784struct Prepared {
785    series: Series,
786    m: usize,
787}
788
789fn prepare(a: &[Arg], c: C, values: usize, timeline: usize, opts: Options) -> R<Prepared> {
790    let series = series(&arg(a, values)?.value, &arg(a, timeline)?.value, &opts, c.date_system())?;
791    let m = match opts.seasonality {
792        Season::Detect => detect_season(&series.y),
793        Season::None => 0,
794        Season::Fixed(m) => m,
795    };
796    Ok(Prepared { series, m })
797}
798
799fn options(a: &[Arg], season: Option<usize>, completion: usize, aggregation: usize) -> R<Options> {
800    Ok(Options {
801        seasonality: match season {
802            Some(i) => season_arg(a, i)?,
803            None => Season::Detect,
804        },
805        interpolate: completion_arg(a, completion)?,
806        aggregate: aggregate_arg(a, aggregation)?,
807    })
808}
809
810fn forecast_ets(a: &[Arg], c: C) -> R<Value> {
811    let target = num(a, 0)?;
812    let opts = options(a, Some(3), 4, 5)?;
813    let p = prepare(a, c, 1, 2, opts)?;
814    let h = p.series.horizon(target)?;
815    num_val(fit(&p.series.y, p.m).forecast(h))
816}
817
818fn forecast_ets_confint(a: &[Arg], c: C) -> R<Value> {
819    let target = num(a, 0)?;
820    let level = if has(a, 3) { num(a, 3)? } else { 0.95 };
821    if !(level > 0.0 && level < 1.0) {
822        return Err(CellError::Num);
823    }
824    let opts = options(a, Some(4), 5, 6)?;
825    let p = prepare(a, c, 1, 2, opts)?;
826    let h = p.series.horizon(target)?;
827    let z = norm_inv((1.0 + level) / 2.0);
828    num_val(z * fit(&p.series.y, p.m).forecast_sd(h))
829}
830
831fn forecast_ets_seasonality(a: &[Arg], c: C) -> R<Value> {
832    let opts = options(a, None, 2, 3)?;
833    let p = prepare(a, c, 0, 1, opts)?;
834    num_val(p.m as f64)
835}
836
837fn forecast_ets_stat(a: &[Arg], c: C) -> R<Value> {
838    let kind = num(a, 2)?;
839    if !kind.is_finite() || !(1.0..9.0).contains(&kind.trunc()) {
840        return Err(CellError::Num);
841    }
842    let kind = kind.trunc() as u8;
843    let opts = options(a, Some(3), 4, 5)?;
844    let p = prepare(a, c, 0, 1, opts)?;
845    if kind == 8 {
846        return num_val(p.series.step);
847    }
848    let y = &p.series.y;
849    let f = fit(y, p.m);
850    let n = f.errors.len().max(1) as f64;
851    let mae = f.errors.iter().map(|e| e.abs()).sum::<f64>() / n;
852    num_val(match kind {
853        1 => f.alpha,
854        2 => f.beta,
855        3 => f.gamma,
856        4 => {
857            let naive: Vec<f64> = y.windows(2).filter_map(|w| Some((w.get(1)? - w.first()?).abs())).collect();
858            let d = naive.iter().sum::<f64>() / naive.len().max(1) as f64;
859            if d == 0.0 {
860                return Err(CellError::Div0);
861            }
862            mae / d
863        }
864        5 => {
865            let terms: Vec<f64> = y
866                .iter()
867                .zip(&f.errors)
868                .filter_map(|(v, e)| {
869                    let denom = (v.abs() + (v - e).abs()) / 2.0;
870                    (denom > 0.0).then(|| e.abs() / denom)
871                })
872                .collect();
873            terms.iter().sum::<f64>() / terms.len().max(1) as f64
874        }
875        6 => mae,
876        7 => (f.errors.iter().map(|e| e * e).sum::<f64>() / n).sqrt(),
877        _ => return Err(CellError::Num),
878    })
879}
880
881const S_ETS: &[bool] = &[true, false, false, true, true, true, true];
882const S_ETS_SEASON: &[bool] = &[false, false, true, true];
883const S_ETS_STAT: &[bool] = &[false, false, true, true, true, true];
884
885pub(crate) fn specs() -> Vec<FnSpec> {
886    vec![
887        f!(
888            "FORECAST.ETS",
889            3,
890            6,
891            Statistical,
892            S_ETS,
893            "FORECAST.ETS(target_date, values, timeline, [seasonality], [data_completion], [aggregation])",
894            "Forecasts a value by additive (AAA) exponential smoothing.",
895            forecast_ets
896        ),
897        f!(
898            "FORECAST.ETS.CONFINT",
899            3,
900            7,
901            Statistical,
902            S_ETS,
903            "FORECAST.ETS.CONFINT(target_date, values, timeline, [confidence_level], [seasonality], [data_completion], [aggregation])",
904            "Half-width of the confidence interval around a FORECAST.ETS value.",
905            forecast_ets_confint
906        ),
907        f!(
908            "FORECAST.ETS.SEASONALITY",
909            2,
910            4,
911            Statistical,
912            S_ETS_SEASON,
913            "FORECAST.ETS.SEASONALITY(values, timeline, [data_completion], [aggregation])",
914            "Length of the repeating pattern detected in a time series.",
915            forecast_ets_seasonality
916        ),
917        f!(
918            "FORECAST.ETS.STAT",
919            3,
920            6,
921            Statistical,
922            S_ETS_STAT,
923            "FORECAST.ETS.STAT(values, timeline, statistic_type, [seasonality], [data_completion], [aggregation])",
924            "A statistic of the FORECAST.ETS model: 1 alpha, 2 beta, 3 gamma, 4 MASE, 5 SMAPE, 6 MAE, 7 RMSE, 8 step size.",
925            forecast_ets_stat
926        ),
927    ]
928}
929
930#[cfg(test)]
931mod tests {
932    use super::*;
933    use crate::functions::util::testutil::*;
934
935    thread_local! {
936        pub(super) static INTERPOLATION_VISITS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
937    }
938
939    #[test]
940    fn interpolation_preserves_gaps_and_endpoint_values() {
941        for (slots, expected) in [
942            (
943                vec![None, Some(4.0), None, None, Some(13.0), Some(-2.0), None, Some(8.0), None, None],
944                vec![4.0, 4.0, 7.0, 10.0, 13.0, -2.0, 3.0, 8.0, 8.0, 8.0],
945            ),
946            (vec![Some(-7.0), None, Some(4.0)], vec![-7.0, -1.5, 4.0]),
947            (vec![Some(3.0), Some(-7.0), Some(2.0)], vec![3.0, -7.0, 2.0]),
948            (vec![None, None, None], vec![0.0, 0.0, 0.0]),
949            (vec![Some(6.0)], vec![6.0]),
950            (vec![], vec![]),
951        ] {
952            assert_eq!(interpolate(&slots), expected, "{slots:?}");
953        }
954    }
955
956    #[test]
957    fn interpolation_lookahead_work_is_linear() {
958        let mut measured = Vec::new();
959        for len in [2_000, 4_000] {
960            let missing = len * 3 / 10;
961            let gap = (len - missing) / 2..(len + missing) / 2;
962            let expected: Vec<f64> = (0..len).map(|i| (3 * i + 5) as f64).collect();
963            let slots: Vec<Option<f64>> = expected.iter().enumerate().map(|(i, &v)| (!gap.contains(&i)).then_some(v)).collect();
964            INTERPOLATION_VISITS.with(|visits| visits.set(0));
965            let actual = interpolate(&slots);
966            let visits = INTERPOLATION_VISITS.with(|visits| visits.get());
967            assert_eq!(actual, expected);
968            measured.push((len, visits));
969        }
970        // Record both sizes before asserting the work bound, including on the old algorithm.
971        eprintln!("interpolation lookahead visits (slots, visits): {measured:?}");
972        for (len, visits) in measured {
973            assert!(visits > 0 && visits <= 2 * len, "{len} slots required {visits} lookahead visits");
974        }
975    }
976
977    #[test]
978    fn series_completes_a_long_gap_at_the_missing_point_limit() {
979        let len = 2_000;
980        let gap = 700..1_300; // Exactly 30% of the completed timeline.
981        let mut times: Vec<f64> = (0..len).filter(|i| !gap.contains(i)).map(|i| (i + 1) as f64).collect();
982        let mut values: Vec<f64> = times.iter().map(|t| 3.0 * t + 2.0).collect();
983        let options = Options::default();
984        let completed = series(&col(&values), &col(&times), &options, DateSystem::D1900).unwrap();
985        let expected: Vec<f64> = (0..len).map(|i| (3 * i + 5) as f64).collect();
986        assert_eq!(completed.y, expected);
987        assert_eq!(completed.step, 1.0);
988        assert_eq!(completed.horizon((len + 1) as f64), Ok(1.0));
989
990        let zeroes = series(&col(&values), &col(&times), &Options { interpolate: false, ..options }, DateSystem::D1900).unwrap();
991        for (i, &value) in zeroes.y.iter().enumerate() {
992            assert_eq!(value, if gap.contains(&i) { 0.0 } else { expected[i] });
993        }
994
995        // Remove the first known point after the gap without changing either timeline endpoint.
996        times.remove(gap.start);
997        values.remove(gap.start);
998        assert!(matches!(series(&col(&values), &col(&times), &options, DateSystem::D1900), Err(CellError::Num)));
999    }
1000
1001    fn range(xs: &[f64]) -> Arg {
1002        rf(col(xs))
1003    }
1004
1005    fn timeline(n: usize) -> Vec<f64> {
1006        (1..=n).map(|t| t as f64).collect()
1007    }
1008
1009    /// y = 3 + 2t plus a four-step season, over `n` points.
1010    fn seasonal(n: usize) -> Vec<f64> {
1011        let pattern = [4.0, -2.0, 7.0, -9.0];
1012        (1..=n).map(|t| 3.0 + 2.0 * t as f64 + pattern[(t - 1) % 4]).collect()
1013    }
1014
1015    #[test]
1016    fn straight_line_forecasts_exactly() {
1017        let ys: Vec<f64> = (1..=20).map(|t| 3.0 + 2.0 * t as f64).collect();
1018        let ts = timeline(20);
1019        close_tol(ev("FORECAST.ETS", vec![n(25.0), range(&ys), range(&ts)]), 53.0, 1e-9);
1020        close_tol(ev("FORECAST.ETS", vec![n(20.5), range(&ys), range(&ts), n(0.0)]), 44.0, 1e-9);
1021        close(ev("FORECAST.ETS.SEASONALITY", vec![range(&ys), range(&ts)]), 0.0);
1022        close_tol(ev("FORECAST.ETS.CONFINT", vec![n(25.0), range(&ys), range(&ts)]), 0.0, 1e-9);
1023        close(ev("FORECAST.ETS.STAT", vec![range(&ys), range(&ts), n(8.0)]), 1.0);
1024        close_tol(ev("FORECAST.ETS.STAT", vec![range(&ys), range(&ts), n(6.0)]), 0.0, 1e-9);
1025        // Target dates are lifted over arrays.
1026        let v = ev("FORECAST.ETS", vec![av(row(&[21.0, 22.0])), range(&ys), range(&ts)]);
1027        let r = rows_of(&v);
1028        close_tol(r[0][0].clone(), 45.0, 1e-9);
1029        close_tol(r[0][1].clone(), 47.0, 1e-9);
1030    }
1031
1032    #[test]
1033    fn seasonal_series_is_detected_and_continued() {
1034        let ys = seasonal(24);
1035        let ts = timeline(24);
1036        close(ev("FORECAST.ETS.SEASONALITY", vec![range(&ys), range(&ts)]), 4.0);
1037        // t = 27 is the third position of the pattern: 3 + 54 + 7.
1038        close_tol(ev("FORECAST.ETS", vec![n(27.0), range(&ys), range(&ts)]), 64.0, 1e-7);
1039        close_tol(ev("FORECAST.ETS", vec![n(28.0), range(&ys), range(&ts), n(4.0)]), 50.0, 1e-7);
1040        // Timeline order does not matter.
1041        let mut rev_y = ys.clone();
1042        let mut rev_t = ts.clone();
1043        rev_y.reverse();
1044        rev_t.reverse();
1045        close_tol(ev("FORECAST.ETS", vec![n(27.0), range(&rev_y), range(&rev_t)]), 64.0, 1e-7);
1046    }
1047
1048    #[test]
1049    fn noisy_series_statistics_are_consistent() {
1050        // A seasonal series with deterministic pseudo-noise.
1051        let ys: Vec<f64> = seasonal(48).iter().enumerate().map(|(i, v)| v + ((i * 37 % 11) as f64 - 5.0) * 0.4).collect();
1052        let ts = timeline(48);
1053        let stat = |k: f64| match ev("FORECAST.ETS.STAT", vec![range(&ys), range(&ts), n(k)]) {
1054            Value::Number(x) => x,
1055            other => panic!("statistic {k}: {other:?}"),
1056        };
1057        for k in [1.0, 2.0, 3.0] {
1058            assert!((0.0..=1.0).contains(&stat(k)), "parameter {k} out of range");
1059        }
1060        let (mase, smape, mae, rmse) = (stat(4.0), stat(5.0), stat(6.0), stat(7.0));
1061        assert!(mae > 0.0 && rmse >= mae && mase > 0.0 && smape > 0.0 && smape < 2.0);
1062        let conf = |t: f64, c: f64| match ev("FORECAST.ETS.CONFINT", vec![n(t), range(&ys), range(&ts), n(c)]) {
1063            Value::Number(x) => x,
1064            other => panic!("confint: {other:?}"),
1065        };
1066        assert!(conf(49.0, 0.95) > 0.0);
1067        assert!(conf(60.0, 0.95) > conf(49.0, 0.95), "the interval widens with the horizon");
1068        assert!(conf(49.0, 0.99) > conf(49.0, 0.95));
1069        // The forecast stays close to the noise-free continuation.
1070        match ev("FORECAST.ETS", vec![n(51.0), range(&ys), range(&ts)]) {
1071            Value::Number(x) => assert!((x - (3.0 + 102.0 + 7.0)).abs() < 3.0, "forecast {x}"),
1072            other => panic!("{other:?}"),
1073        }
1074    }
1075
1076    #[test]
1077    fn monthly_dates_missing_points_and_duplicates() {
1078        // First of each month, 2024-01 … 2025-12 (serials of 1 Jan 2024 = 45292).
1079        let mut ts = Vec::new();
1080        let mut serial = 45292.0;
1081        for i in 0..24 {
1082            ts.push(serial);
1083            let (y, m) = (2024 + i / 12, (i % 12) as u32 + 1);
1084            serial += f64::from(crate::base::date::days_in_month(y, m));
1085        }
1086        let ys: Vec<f64> = (0..24).map(|i| 100.0 + 5.0 * i as f64).collect();
1087        // 2026-01-01 = 46023, 2026-03-01 = 46082.
1088        close_tol(ev("FORECAST.ETS", vec![n(46023.0), range(&ys), range(&ts), n(0.0)]), 220.0, 1e-9);
1089        close_tol(ev("FORECAST.ETS", vec![n(46082.0), range(&ys), range(&ts), n(0.0)]), 230.0, 1e-9);
1090        // Missing points are interpolated (or zero with data_completion 0).
1091        let gaps_t: Vec<f64> = [1.0, 2.0, 4.0, 5.0, 6.0, 8.0, 9.0, 10.0].to_vec();
1092        let gaps_y: Vec<f64> = gaps_t.iter().map(|t| 10.0 * t).collect();
1093        close_tol(ev("FORECAST.ETS", vec![n(12.0), range(&gaps_y), range(&gaps_t), n(0.0)]), 120.0, 1e-9);
1094        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));
1095        // Duplicate time stamps are averaged by default, summed with aggregation 7.
1096        let dup_t = [1.0, 2.0, 2.0, 3.0, 4.0, 5.0];
1097        let dup_y = [2.0, 3.0, 5.0, 6.0, 8.0, 10.0];
1098        close_tol(ev("FORECAST.ETS", vec![n(6.0), range(&dup_y), range(&dup_t), n(0.0)]), 12.0, 1e-9);
1099        close_tol(ev("FORECAST.ETS.STAT", vec![range(&dup_y), range(&dup_t), n(8.0)]), 1.0, 1e-12);
1100        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));
1101    }
1102
1103    #[test]
1104    fn documented_errors() {
1105        let ys = seasonal(12);
1106        let ts = timeline(12);
1107        let ets = |args: Vec<Arg>| ev("FORECAST.ETS", args);
1108        // Target before the end of the timeline.
1109        is_err(ets(vec![n(5.0), range(&ys), range(&ts)]), CellError::Num);
1110        // Timeline and values of different sizes.
1111        is_err(ets(vec![n(13.0), range(&ys), range(&ts[..11])]), CellError::NA);
1112        // No constant step.
1113        is_err(ets(vec![n(5.0), range(&[1.0, 2.0, 3.0]), range(&[1.0, 2.0, 4.5])]), CellError::Num);
1114        // Seasonality, completion, aggregation out of range.
1115        for (i, bad) in [(3, 8761.0), (3, -1.0), (4, 2.0), (5, 8.0)] {
1116            let mut args = vec![n(13.0), range(&ys), range(&ts), n(1.0), n(1.0), n(0.0)];
1117            args[i] = n(bad);
1118            is_err(ets(args), CellError::Num);
1119        }
1120        // More than 30% of the points missing.
1121        is_err(ets(vec![n(30.0), range(&[1.0, 2.0, 3.0]), range(&[1.0, 2.0, 20.0])]), CellError::Num);
1122        // Text time stamps; errors propagate.
1123        is_err(ets(vec![n(13.0), range(&[1.0, 2.0]), rf(arr(vec![vec![nv(1.0)], vec![tv("x")]]))]), CellError::Value);
1124        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);
1125        // Confidence level outside (0, 1); statistic type outside 1–8.
1126        is_err(ev("FORECAST.ETS.CONFINT", vec![n(13.0), range(&ys), range(&ts), n(1.0)]), CellError::Num);
1127        is_err(ev("FORECAST.ETS.CONFINT", vec![n(13.0), range(&ys), range(&ts), n(0.0)]), CellError::Num);
1128        is_err(ev("FORECAST.ETS.STAT", vec![range(&ys), range(&ts), n(9.0)]), CellError::Num);
1129        is_err(ev("FORECAST.ETS.STAT", vec![range(&ys), range(&ts), n(0.0)]), CellError::Num);
1130        // Hostile spans: a huge gap is rejected before anything is allocated.
1131        is_err(ets(vec![n(1e15), range(&[1.0, 2.0, 3.0]), range(&[0.0, 1.0, 1e12])]), CellError::Num);
1132        is_err(ets(vec![n(1e308), range(&ys), range(&ts)]), CellError::Num);
1133    }
1134
1135    #[test]
1136    fn timelines_continue_past_their_last_point() {
1137        let tl = Timeline::detect(&[3.0, 1.0, 2.0, 2.0, 5.0], DateSystem::D1900).expect("numeric step");
1138        assert_eq!(tl.after_last(1), Some(6.0));
1139        assert_eq!(tl.steps_after_last(8.5), Some(3.5));
1140        // Month ends: 2024-01-31 (45322), 2024-02-29 (45351), 2024-03-31 (45382).
1141        let tl = Timeline::detect(&[45322.0, 45351.0, 45382.0], DateSystem::D1900).expect("months");
1142        assert_eq!(tl.after_last(1), Some(45412.0)); // 2024-04-30
1143        assert_eq!(tl.steps_after_last(45412.0), Some(1.0));
1144        assert!(Timeline::detect(&[1.0, 2.0, 4.5], DateSystem::D1900).is_none());
1145        assert!(Timeline::detect(&[1.0, 2.0, 9.0], DateSystem::D1900).is_none(), "too many missing points");
1146        assert!(Timeline::detect(&[1.0], DateSystem::D1900).is_none());
1147        assert!(Timeline::detect(&[1.0, f64::NAN], DateSystem::D1900).is_none());
1148    }
1149}