Skip to main content

pond_rs/
smooth.rs

1//! Smoothing and band statistics: `smooth` (EMA, moving average, loess),
2//! `baseline`, `outliers`, `scan` and `sample`, ported from pond-ts so
3//! results match it bit for bit.
4//!
5//! On a series holding several entities (hosts, devices) each of these mixes
6//! the entities' rows together, as in pond-ts.
7
8use crate::column::ColumnData;
9use crate::duration::parse_duration;
10use crate::error::{PondError, Result};
11use crate::reducer::Reducer;
12use crate::rolling::{Alignment, Frame, Rolling, Span, check_magnitude, roll_column};
13use crate::series::{Column, Key, TimeSeries};
14
15/// How [`TimeSeries::smooth`] smooths a column, with its options. Build one
16/// with [`Smooth::ema`], [`Smooth::ema_span`], [`Smooth::moving_average`]
17/// or [`Smooth::loess`], then add options:
18///
19/// ```
20/// # use pond_rs::{Alignment, Smooth};
21/// let fast = Smooth::ema(0.2).min_samples(5).output("cpu_ema");
22/// let local = Smooth::moving_average("5m").alignment(Alignment::Centered).skip_missing();
23/// ```
24///
25/// An option that does not belong to the method (a `warmup` on a loess,
26/// say) makes [`TimeSeries::smooth`] return an error.
27#[derive(Debug, Clone, PartialEq)]
28pub struct Smooth {
29    method: Method,
30    output: Option<String>,
31    warmup: Option<usize>,
32    min_samples: Option<usize>,
33    alignment: Option<Alignment>,
34    skip_missing: bool,
35}
36
37#[derive(Debug, Clone, PartialEq)]
38enum Method {
39    Ema(Rate),
40    MovingAverage(String),
41    Loess(f64),
42}
43
44#[derive(Debug, Clone, Copy, PartialEq)]
45enum Rate {
46    Alpha(f64),
47    Span(f64),
48}
49
50impl Smooth {
51    fn new(method: Method) -> Self {
52        Smooth {
53            method,
54            output: None,
55            warmup: None,
56            min_samples: None,
57            alignment: None,
58            skip_missing: false,
59        }
60    }
61
62    /// An exponential moving average with weight `alpha` in `(0, 1]` on the
63    /// newest value: the first value is kept as it is, then each value `v`
64    /// gives `alpha * v + (1 - alpha) * previous` (pond-ts `'ema'` with
65    /// `alpha`). A gap gives a gap and leaves `previous` as it was.
66    pub fn ema(alpha: f64) -> Self {
67        Self::new(Method::Ema(Rate::Alpha(alpha)))
68    }
69
70    /// An exponential moving average over `span` bars, the finance
71    /// convention: `alpha = 2 / (span + 1)`, with `span` at least 1
72    /// (pond-ts `'ema'` with `span`).
73    pub fn ema_span(span: f64) -> Self {
74        Self::new(Method::Ema(Rate::Span(span)))
75    }
76
77    /// The mean of the values in a time window around each row (pond-ts
78    /// `'movingAverage'`): trailing `(t - window, t]` by default. Rows
79    /// sharing a time share a window. A row keyed by a time range or an
80    /// interval sits at its middle, which may be half a millisecond.
81    /// A window with no values gives a gap. A value of 2^960 or more is
82    /// refused, as by [`TimeSeries::rolling`] (pond-ts gives a number).
83    pub fn moving_average(window: &str) -> Self {
84        Self::new(Method::MovingAverage(window.to_string()))
85    }
86
87    /// A local straight-line fit at each row (pond-ts `'loess'`): the
88    /// `span` fraction (in `(0, 1]`) of the values nearest in time, at
89    /// least 2, weighted by the tricube of their distance, as pond-ts fits
90    /// it. Rows sit in time as for [`Smooth::moving_average`]. Each row
91    /// costs time in proportion to the rows in its fit, so the whole call
92    /// grows with the square of the row count.
93    pub fn loess(span: f64) -> Self {
94        Self::new(Method::Loess(span))
95    }
96
97    /// Write the result to a column with this name instead of replacing
98    /// the source column. It must not be the key's name or another
99    /// existing column; naming the source column replaces it.
100    pub fn output(mut self, name: &str) -> Self {
101        self.output = Some(name.to_string());
102        self
103    }
104
105    /// EMA only: drop the first `rows` rows from the result, hiding the
106    /// average's first steps (it still reads them). This changes the row
107    /// count; [`Smooth::min_samples`] does not.
108    pub fn warmup(mut self, rows: usize) -> Self {
109        self.warmup = Some(rows);
110        self
111    }
112
113    /// EMA only: a gap until this many values have been read; the rows stay.
114    pub fn min_samples(mut self, values: usize) -> Self {
115        self.min_samples = Some(values);
116        self
117    }
118
119    /// Moving average only: where the window sits around each row
120    /// ([`Alignment::Trailing`] by default).
121    pub fn alignment(mut self, alignment: Alignment) -> Self {
122        self.alignment = Some(alignment);
123        self
124    }
125
126    /// Moving average and loess: keep a gap where the source has one
127    /// (pond-ts `missing: 'skip'`). By default a gap gets the value fitted
128    /// from the values around it (`missing: 'bridge'`).
129    pub fn skip_missing(mut self) -> Self {
130        self.skip_missing = true;
131        self
132    }
133
134    /// An error naming the first option set that `method` does not take.
135    fn check_options(&self) -> Result<()> {
136        let (method, ema, moving, loess) = match self.method {
137            Method::Ema(_) => ("ema", true, false, false),
138            Method::MovingAverage(_) => ("moving_average", false, true, false),
139            Method::Loess(_) => ("loess", false, false, true),
140        };
141        let misplaced = [
142            ("warmup", self.warmup.is_some() && !ema),
143            ("min_samples", self.min_samples.is_some() && !ema),
144            ("alignment", self.alignment.is_some() && !moving),
145            ("skip_missing", self.skip_missing && !(moving || loess)),
146        ];
147        match misplaced.iter().find(|(_, bad)| *bad) {
148            None => Ok(()),
149            Some((option, _)) => Err(invalid(format!("{method} does not take {option}"))),
150        }
151    }
152}
153
154const SMOOTH: &str = "smooth";
155
156fn invalid(message: String) -> PondError {
157    PondError::InvalidData {
158        call: SMOOTH,
159        message,
160    }
161}
162
163/// Where each row sits in time for `smooth`: a time key's time, or the
164/// middle of a time range / interval, as pond-ts computes it in f64
165/// (`begin + (end - begin) / 2`, which may be half a millisecond).
166fn anchors(key: &Key) -> Vec<f64> {
167    let begin = key.begin();
168    match key.end() {
169        None => begin.iter().map(|&b| b as f64).collect(),
170        Some(end) => begin
171            .iter()
172            .zip(end)
173            .map(|(&b, &e)| b as f64 + (e - b) as f64 / 2.0)
174            .collect(),
175    }
176}
177
178/// Row indexes in anchor order (stable). Time-range and interval keys sort
179/// by begin, so their middles can run backwards (a long range before a
180/// short one); the moving-average sweep and the loess search need them in
181/// order (pond-ts [PND-SMOOTHMID]). `None` when they already are.
182fn anchor_order(anchors: &[f64]) -> Option<Vec<usize>> {
183    if anchors.windows(2).all(|w| w[0] <= w[1]) {
184        return None;
185    }
186    let mut order: Vec<usize> = (0..anchors.len()).collect();
187    order.sort_by(|&a, &b| anchors[a].total_cmp(&anchors[b]));
188    Some(order)
189}
190
191/// pond-ts `loessAt`: the local straight-line fit at `x` over the values
192/// at sorted `anchors`. `None` when there are no values.
193fn loess_at(x: f64, anchors: &[f64], values: &[f64], span: f64) -> Option<f64> {
194    let n = anchors.len();
195    match n {
196        0 => return None,
197        1 => return Some(values[0]),
198        _ => {}
199    }
200    let neighbours = ((span * n as f64).ceil() as usize).clamp(2, n);
201    // The run of `neighbours` anchors nearest `x`: the first start whose
202    // window's far end is no further from `x` than its near end.
203    let (mut low, mut high) = (0, n - neighbours);
204    while low < high {
205        let mid = (low + high) / 2;
206        if x - anchors[mid] > anchors[mid + neighbours] - x {
207            low = mid + 1;
208        } else {
209            high = mid;
210        }
211    }
212    let (start, end) = (low, low + neighbours);
213    let bandwidth = (x - anchors[start]).abs().max((anchors[end - 1] - x).abs());
214    if bandwidth == 0.0 {
215        // Every neighbour sits at `x`: the mean of the values there.
216        let lo = anchors.partition_point(|&a| a < x);
217        let hi = anchors.partition_point(|&a| a <= x);
218        let sum = values[lo..hi].iter().fold(0.0, |acc, v| acc + v);
219        return Some(sum / (hi - lo) as f64);
220    }
221    let (mut weighted, mut sw, mut swx, mut swy, mut swxx, mut swxy) =
222        (0usize, 0.0, 0.0, 0.0, 0.0, 0.0);
223    for i in start..end {
224        // Centred on `x`, so the fit at `x` is the intercept.
225        let dx = anchors[i] - x;
226        let y = values[i];
227        let ratio = dx.abs() / bandwidth;
228        // Tricube, as products (pond-ts [PND-LOESSPOW]).
229        let cube = ratio * ratio * ratio;
230        let tail = 1.0 - cube;
231        let w = if ratio >= 1.0 {
232            0.0
233        } else {
234            tail * tail * tail
235        };
236        if w == 0.0 {
237            continue;
238        }
239        weighted += 1;
240        sw += w;
241        swx += w * dx;
242        swy += w * y;
243        swxx += w * dx * dx;
244        swxy += w * dx * y;
245    }
246    if weighted == 0 || sw == 0.0 {
247        return None;
248    }
249    let denominator = sw * swxx - swx * swx;
250    if denominator.abs() < f64::EPSILON {
251        return Some(swy / sw);
252    }
253    Some((swy * swxx - swx * swxy) / denominator)
254}
255
256/// `values` with a gap wherever the source row is a gap.
257fn skip_gaps(mut values: Vec<f64>, source: &[f64]) -> Vec<f64> {
258    for (v, s) in values.iter_mut().zip(source) {
259        if !s.is_finite() {
260            *v = f64::NAN;
261        }
262    }
263    values
264}
265
266/// A number result, with non-finite values as gaps.
267fn finite_or_gap(v: f64) -> f64 {
268    if v.is_finite() { v } else { f64::NAN }
269}
270
271/// How [`TimeSeries::sample`] picks rows.
272#[derive(Debug, Clone, Copy, PartialEq, Eq)]
273pub enum Sample {
274    /// Every `n`th row: rows `n - 1`, `2n - 1`, … (pond-ts `{ stride }`).
275    /// `n` must be at least 1.
276    Stride(usize),
277    /// `size` rows picked at random, every row equally likely (pond-ts
278    /// `{ reservoir: { size } }`, one pass of Algorithm R), kept in their
279    /// order in the series. The same `seed` picks the same rows; pond-ts
280    /// has no seed. A series of at most `size` rows is kept whole. `size`
281    /// must be at least 1.
282    Reservoir {
283        /// How many rows to keep.
284        size: usize,
285        /// Seed for the random picks.
286        seed: u64,
287    },
288}
289
290/// SplitMix64: a small, fast generator, enough for picking rows.
291struct SplitMix64(u64);
292
293impl SplitMix64 {
294    fn next(&mut self) -> u64 {
295        self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
296        let mut z = self.0;
297        z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
298        z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
299        z ^ (z >> 31)
300    }
301
302    /// A whole number in `0..bound`, from the top bits of a 128-bit
303    /// product (Lemire's method, without its rejection step: the bias is
304    /// below `bound / 2^64`).
305    fn below(&mut self, bound: u64) -> u64 {
306        ((self.next() as u128 * bound as u128) >> 64) as u64
307    }
308}
309
310impl TimeSeries {
311    /// The source column as numbers (gaps as NaN), or a wrong-kind error.
312    pub(crate) fn number_source(
313        &self,
314        call: &'static str,
315        column: &str,
316    ) -> Result<(usize, &[f64])> {
317        let c = self.column_index(call, column)?;
318        let source = &self.columns()[c];
319        match source.data() {
320            ColumnData::Number(v) => Ok((c, v.values())),
321            other => Err(PondError::WrongKind {
322                call,
323                column: source.name().to_string(),
324                kind: other.kind().as_str(),
325                reason: "needs a number column".to_string(),
326            }),
327        }
328    }
329
330    /// An error if `name` is the key's or a value column's name.
331    fn check_new_name(&self, call: &'static str, name: &str) -> Result<()> {
332        if name == self.key_name() || self.columns().iter().any(|c| c.name() == name) {
333            return Err(PondError::DuplicateOutput {
334                call,
335                column: name.to_string(),
336            });
337        }
338        Ok(())
339    }
340
341    /// This series with column `c` replaced by `values` (optional), or with
342    /// `values` added as a new optional column `output`.
343    fn put_numbers(&self, c: usize, output: Option<&str>, values: Vec<f64>) -> TimeSeries {
344        let mut columns = self.columns().to_vec();
345        match output {
346            None => {
347                columns[c] = Column::from_data(columns[c].name().to_string(), values, false);
348            }
349            Some(name) => columns.push(Column::from_data(name.to_string(), values, false)),
350        }
351        self.with_value_columns(columns)
352    }
353
354    /// Smooth one number column (pond-ts `smooth(column, method,
355    /// options)`); see [`Smooth`] for the methods. The result replaces the
356    /// column (now optional), or goes to a new optional column named by
357    /// [`Smooth::output`]. Other columns and the key are kept.
358    ///
359    /// ```
360    /// # use pond_rs::{Column, Smooth, TimeSeries};
361    /// let s = TimeSeries::new("s", vec![0, 1000, 2000],
362    ///     vec![Column::required("v", vec![2.0, 4.0, 8.0])])?;
363    /// let e = s.smooth("v", &Smooth::ema(0.5).output("v_ema"))?;
364    /// let got: Vec<_> = e.column("v_ema")?.numbers()?.iter().collect();
365    /// assert_eq!(got, [Some(2.0), Some(3.0), Some(5.5)]);
366    /// # Ok::<(), pond_rs::PondError>(())
367    /// ```
368    ///
369    /// Errors, where pond-ts is silent: an unknown column (pond-ts passes
370    /// the series through) and a column that is not numbers (pond-ts gives
371    /// a gap in every row). A non-finite result (an overflow) is a gap.
372    pub fn smooth(&self, column: &str, smooth: &Smooth) -> Result<TimeSeries> {
373        smooth.check_options()?;
374        let (c, values) = self.number_source(SMOOTH, column)?;
375        let output = smooth.output.as_deref().filter(|&o| o != column);
376        if let Some(name) = output {
377            self.check_new_name(SMOOTH, name)?;
378        }
379        match &smooth.method {
380            Method::Ema(rate) => {
381                let alpha = match *rate {
382                    Rate::Alpha(a) if a.is_finite() && a > 0.0 && a <= 1.0 => a,
383                    Rate::Alpha(a) => {
384                        return Err(invalid(format!("ema alpha must be in (0, 1], got {a}")));
385                    }
386                    Rate::Span(s) if s.is_finite() && s >= 1.0 => 2.0 / (s + 1.0),
387                    Rate::Span(s) => {
388                        return Err(invalid(format!(
389                            "ema span must be a finite number >= 1, got {s}"
390                        )));
391                    }
392                };
393                let min_samples = smooth.min_samples.unwrap_or(0);
394                let mut previous: Option<f64> = None;
395                let mut seen = 0;
396                let out: Vec<f64> = values
397                    .iter()
398                    .map(|&raw| {
399                        if !raw.is_finite() {
400                            return f64::NAN;
401                        }
402                        let v = previous.map_or(raw, |p| alpha * raw + (1.0 - alpha) * p);
403                        previous = Some(v);
404                        seen += 1;
405                        if seen < min_samples {
406                            f64::NAN
407                        } else {
408                            finite_or_gap(v)
409                        }
410                    })
411                    .collect();
412                let result = self.put_numbers(c, output, out);
413                let warmup = smooth.warmup.unwrap_or(0).min(result.len());
414                Ok(if warmup == 0 {
415                    result
416                } else {
417                    result.row_range(warmup..result.len())
418                })
419            }
420            Method::MovingAverage(window) => {
421                let window_ms = parse_duration(window)?;
422                check_magnitude(SMOOTH, &self.columns()[c], Reducer::Avg)?;
423                let mut times = anchors(self.key());
424                let order = anchor_order(&times);
425                let sorted_values: Vec<f64>;
426                let in_order = match &order {
427                    None => values,
428                    Some(order) => {
429                        times = order.iter().map(|&i| times[i]).collect();
430                        sorted_values = order.iter().map(|&i| values[i]).collect();
431                        &sorted_values
432                    }
433                };
434                let frame = Frame {
435                    times: &times,
436                    span: Span::Time(window_ms),
437                    alignment: smooth.alignment.unwrap_or_default(),
438                    min_samples: 0,
439                };
440                let mut out = roll_column(&frame, in_order, Reducer::Avg);
441                if let Some(order) = order {
442                    let mut by_row = vec![f64::NAN; out.len()];
443                    for (k, &row) in order.iter().enumerate() {
444                        by_row[row] = out[k];
445                    }
446                    out = by_row;
447                }
448                if smooth.skip_missing {
449                    out = skip_gaps(out, values);
450                }
451                Ok(self.put_numbers(c, output, out))
452            }
453            Method::Loess(span) => {
454                let span = *span;
455                if !(span.is_finite() && span > 0.0 && span <= 1.0) {
456                    return Err(invalid(format!("loess span must be in (0, 1], got {span}")));
457                }
458                let times = anchors(self.key());
459                let rows: Vec<usize> = match anchor_order(&times) {
460                    None => (0..times.len()).collect(),
461                    Some(order) => order,
462                };
463                let (fit_at, fit_values): (Vec<f64>, Vec<f64>) = rows
464                    .iter()
465                    .filter(|&&i| values[i].is_finite())
466                    .map(|&i| (times[i], values[i]))
467                    .unzip();
468                let rows: Vec<usize> = (0..times.len()).collect();
469                // Each row's fit stands alone, so rows can run on any core.
470                let out: Vec<f64> = crate::par::map(&rows, |&i| {
471                    if smooth.skip_missing && !values[i].is_finite() {
472                        return f64::NAN;
473                    }
474                    loess_at(times[i], &fit_at, &fit_values, span).map_or(f64::NAN, finite_or_gap)
475                });
476                Ok(self.put_numbers(c, output, out))
477            }
478        }
479    }
480
481    /// The rolling mean and standard deviation of `column` over a time
482    /// window, with a band `sigma` deviations either side (pond-ts
483    /// `baseline(column, { window, sigma, alignment, minSamples })`). Adds
484    /// four optional columns, `avg`, `sd`, `upper` and `lower`, keeping the
485    /// rest; see [`TimeSeries::baseline_named`] for other names.
486    ///
487    /// The window is as in [`TimeSeries::rolling_with`] (`sd` is the
488    /// population standard deviation). A row whose window has no values,
489    /// or fewer rows than `options.min_samples`, gets gaps in all four. A
490    /// flat window (`sd` is 0) keeps `avg` and `sd` but gives gaps in
491    /// `upper` and `lower`: a band of zero width would flag every other
492    /// value.
493    ///
494    /// ```
495    /// # use pond_rs::{Column, Rolling, TimeSeries};
496    /// let s = TimeSeries::new("cpu", vec![0, 1000, 2000],
497    ///     vec![Column::required("cpu", vec![1.0, 3.0, 5.0])])?;
498    /// let b = s.baseline("cpu", "2s", 1.0, Rolling::default())?;
499    /// assert_eq!(b.column("upper")?.numbers()?.get(2), Some(5.0)); // 4 + 1
500    /// # Ok::<(), pond_rs::PondError>(())
501    /// ```
502    ///
503    /// `sigma` must be a positive finite number, and `column` a number
504    /// column (pond-ts gives gaps for any other kind). A value of 2^480 or
505    /// more is refused, as by a rolling standard deviation (pond-ts gives
506    /// a number).
507    pub fn baseline(
508        &self,
509        column: &str,
510        window: &str,
511        sigma: f64,
512        options: Rolling,
513    ) -> Result<TimeSeries> {
514        self.baseline_named(
515            column,
516            window,
517            sigma,
518            options,
519            ["avg", "sd", "upper", "lower"],
520        )
521    }
522
523    /// [`TimeSeries::baseline`] with the four new columns named `names`, in
524    /// the order avg, sd, upper, lower (pond-ts `names`). The names must be
525    /// new and distinct: not the key's name, an existing column or each
526    /// other (pond-ts checks only the existing columns).
527    pub fn baseline_named(
528        &self,
529        column: &str,
530        window: &str,
531        sigma: f64,
532        options: Rolling,
533        names: [&str; 4],
534    ) -> Result<TimeSeries> {
535        const CALL: &str = "baseline";
536        for (i, name) in names.iter().enumerate() {
537            self.check_new_name(CALL, name)?;
538            if names[..i].contains(name) {
539                return Err(PondError::DuplicateOutput {
540                    call: CALL,
541                    column: name.to_string(),
542                });
543            }
544        }
545        let (avg, sd) = self.band(CALL, column, window, sigma, options)?;
546        let (upper, lower): (Vec<f64>, Vec<f64>) = avg
547            .iter()
548            .zip(&sd)
549            .map(|(&a, &s)| {
550                // A gap in `sd` is NaN, which fails `> 0`.
551                if s > 0.0 {
552                    (a + sigma * s, a - sigma * s)
553                } else {
554                    (f64::NAN, f64::NAN)
555                }
556            })
557            .unzip();
558        let mut columns = self.columns().to_vec();
559        for (name, values) in names.iter().zip([avg, sd, upper, lower]) {
560            columns.push(Column::from_data(name.to_string(), values, false));
561        }
562        Ok(self.with_value_columns(columns))
563    }
564
565    /// The rows whose `column` value is more than `sigma` rolling standard
566    /// deviations from the rolling mean (pond-ts `outliers(column, {
567    /// window, sigma, alignment, minSamples })`): the rows
568    /// [`TimeSeries::baseline`] puts outside its band. Rows with a gap, a
569    /// flat window (`sd` is 0) or no baseline are never outliers. The
570    /// result keeps every column. Errors as [`TimeSeries::baseline`].
571    ///
572    /// ```
573    /// # use pond_rs::{Column, Rolling, TimeSeries};
574    /// let s = TimeSeries::new("cpu", vec![0, 1000, 2000, 3000],
575    ///     vec![Column::required("cpu", vec![1.0, 1.0, 1.0, 9.0])])?;
576    /// let o = s.outliers("cpu", "4s", 1.0, Rolling::default())?;
577    /// assert_eq!(o.key().begin(), &[3000]);
578    /// # Ok::<(), pond_rs::PondError>(())
579    /// ```
580    pub fn outliers(
581        &self,
582        column: &str,
583        window: &str,
584        sigma: f64,
585        options: Rolling,
586    ) -> Result<TimeSeries> {
587        const CALL: &str = "outliers";
588        let (avg, sd) = self.band(CALL, column, window, sigma, options)?;
589        let (_, values) = self.number_source(CALL, column)?;
590        let rows: Vec<usize> = (0..self.len())
591            .filter(|&i| {
592                let (v, a, s) = (values[i], avg[i], sd[i]);
593                // Gaps are NaN: every comparison with one is false.
594                s > 0.0 && (v - a).abs() > sigma * s
595            })
596            .collect();
597        Ok(self.take_rows(&rows))
598    }
599
600    /// The rolling mean and standard deviation for `baseline` / `outliers`.
601    fn band(
602        &self,
603        call: &'static str,
604        column: &str,
605        window: &str,
606        sigma: f64,
607        options: Rolling,
608    ) -> Result<(Vec<f64>, Vec<f64>)> {
609        if !(sigma.is_finite() && sigma > 0.0) {
610            return Err(PondError::InvalidData {
611                call,
612                message: format!("sigma must be a positive finite number, got {sigma}"),
613            });
614        }
615        let (c, values) = self.number_source(call, column)?;
616        let source = &self.columns()[c];
617        check_magnitude(call, source, Reducer::Avg)?;
618        check_magnitude(call, source, Reducer::Stdev)?;
619        let window_ms = parse_duration(window)?;
620        // Range- and interval-keyed rows sit at their begin, as in rolling.
621        let frame = Frame {
622            times: self.key().begin(),
623            span: Span::Time(window_ms),
624            alignment: options.alignment,
625            min_samples: options.min_samples,
626        };
627        let reducers = [Reducer::Avg, Reducer::Stdev];
628        let mut both = crate::par::map(&reducers, |&r| roll_column(&frame, values, r));
629        let sd = both.pop().expect("two reducers");
630        let avg = both.pop().expect("two reducers");
631        Ok((avg, sd))
632    }
633
634    /// A running fold of your own down one number column, replacing it
635    /// (pond-ts `scan(column, step, init)`): `step(acc, value, row)`
636    /// returns the next accumulator and this row's output. The accumulator
637    /// can be any type, so the running state can be more than the output
638    /// (a reference level and a total, say). See [`TimeSeries::scan_to`]
639    /// to keep the source column.
640    ///
641    /// A gap does not call `step`: the accumulator is kept and the row
642    /// repeats the last output (a gap before the first value). A
643    /// non-finite output is a gap. The column becomes optional.
644    ///
645    /// ```
646    /// # use pond_rs::{Column, TimeSeries};
647    /// // Total climb, counting only rises from the last low point.
648    /// let s = TimeSeries::new("track", vec![0, 1, 2, 3],
649    ///     vec![Column::required("ele", vec![10.0, 12.0, 11.0, 14.0])])?;
650    /// let gain = s.scan("ele", (f64::INFINITY, 0.0), |(low, total), v, _row| {
651    ///     let total = if v > low { total + v - low } else { total };
652    ///     ((v, total), total)
653    /// })?;
654    /// let got: Vec<_> = gain.column("ele")?.numbers()?.iter().collect();
655    /// assert_eq!(got, [Some(0.0), Some(2.0), Some(2.0), Some(5.0)]);
656    /// # Ok::<(), pond_rs::PondError>(())
657    /// ```
658    ///
659    /// Errors: an unknown column, or one that is not numbers.
660    pub fn scan<A>(
661        &self,
662        column: &str,
663        init: A,
664        step: impl FnMut(A, f64, usize) -> (A, f64),
665    ) -> Result<TimeSeries> {
666        self.scan_into(column, None, init, step)
667    }
668
669    /// [`TimeSeries::scan`], writing the outputs to a new optional column
670    /// `output` (pond-ts `{ output }`) and keeping the source. `output`
671    /// must be a new name.
672    pub fn scan_to<A>(
673        &self,
674        column: &str,
675        output: &str,
676        init: A,
677        step: impl FnMut(A, f64, usize) -> (A, f64),
678    ) -> Result<TimeSeries> {
679        self.scan_into(column, Some(output), init, step)
680    }
681
682    fn scan_into<A>(
683        &self,
684        column: &str,
685        output: Option<&str>,
686        init: A,
687        mut step: impl FnMut(A, f64, usize) -> (A, f64),
688    ) -> Result<TimeSeries> {
689        const CALL: &str = "scan";
690        let (c, values) = self.number_source(CALL, column)?;
691        if let Some(name) = output {
692            self.check_new_name(CALL, name)?;
693        }
694        let mut acc = Some(init);
695        let mut last = f64::NAN;
696        let out: Vec<f64> = values
697            .iter()
698            .enumerate()
699            .map(|(i, &v)| {
700                if v.is_finite() {
701                    let (next, o) = step(acc.take().expect("accumulator is put back"), v, i);
702                    acc = Some(next);
703                    last = finite_or_gap(o);
704                }
705                last
706            })
707            .collect();
708        Ok(self.put_numbers(c, output, out))
709    }
710
711    /// Some of the rows, in order (pond-ts `sample`); see [`Sample`].
712    ///
713    /// ```
714    /// # use pond_rs::{Column, Sample, TimeSeries};
715    /// let s = TimeSeries::new("s", (0..10).map(|i| i * 1000).collect(),
716    ///     vec![Column::required("v", (0..10).map(f64::from).collect::<Vec<_>>())])?;
717    /// assert_eq!(s.sample(Sample::Stride(4))?.key().begin(), &[3000, 7000]);
718    /// assert_eq!(s.sample(Sample::Reservoir { size: 3, seed: 7 })?.len(), 3);
719    /// # Ok::<(), pond_rs::PondError>(())
720    /// ```
721    pub fn sample(&self, how: Sample) -> Result<TimeSeries> {
722        const CALL: &str = "sample";
723        let n = self.len();
724        let rows: Vec<usize> = match how {
725            Sample::Stride(0) | Sample::Reservoir { size: 0, .. } => {
726                return Err(PondError::InvalidData {
727                    call: CALL,
728                    message: "needs a stride or size of at least 1".into(),
729                });
730            }
731            Sample::Stride(stride) => (stride - 1..n).step_by(stride).collect(),
732            Sample::Reservoir { size, .. } if n <= size => return Ok(self.clone()),
733            Sample::Reservoir { size, seed } => {
734                let mut rng = SplitMix64(seed);
735                let mut picked: Vec<usize> = (0..size).collect();
736                for i in size..n {
737                    let j = rng.below(i as u64 + 1) as usize;
738                    if j < size {
739                        picked[j] = i;
740                    }
741                }
742                picked.sort_unstable();
743                picked
744            }
745        };
746        Ok(self.take_rows(&rows))
747    }
748}
749
750#[cfg(test)]
751mod tests {
752    use super::*;
753
754    fn series(times: Vec<i64>, values: Vec<Option<f64>>) -> TimeSeries {
755        TimeSeries::new("s", times, vec![Column::optional("v", values)]).unwrap()
756    }
757
758    fn numbers(s: &TimeSeries, name: &str) -> Vec<Option<f64>> {
759        s.column(name).unwrap().numbers().unwrap().iter().collect()
760    }
761
762    #[test]
763    fn options_must_fit_the_method() {
764        let s = series(vec![0, 1], vec![Some(1.0), Some(2.0)]);
765        for bad in [
766            Smooth::loess(0.5).warmup(1),
767            Smooth::moving_average("1s").min_samples(1),
768            Smooth::ema(0.5).alignment(Alignment::Leading),
769            Smooth::ema(0.5).skip_missing(),
770        ] {
771            let e = s.smooth("v", &bad).unwrap_err();
772            assert!(e.to_string().contains("does not take"), "{e}");
773        }
774    }
775
776    #[test]
777    fn loess_fits_a_line_exactly() {
778        // On points on a straight line every local fit is that line.
779        let t: Vec<i64> = (0..20).map(|i| i * 1000).collect();
780        let v: Vec<Option<f64>> = (0..20).map(|i| Some(3.0 * i as f64 - 7.0)).collect();
781        let s = series(t, v.clone());
782        let fit = s.smooth("v", &Smooth::loess(0.4)).unwrap();
783        for (got, want) in numbers(&fit, "v").iter().zip(&v) {
784            assert!((got.unwrap() - want.unwrap()).abs() < 1e-9);
785        }
786    }
787
788    #[test]
789    fn scan_carries_over_gaps_and_drops_non_finite() {
790        let s = series(
791            vec![0, 1, 2, 3, 4],
792            vec![None, Some(1.0), None, Some(0.0), Some(2.0)],
793        );
794        // Output 1 / value: infinity at the zero, then back.
795        let r = s
796            .scan_to("v", "inv", 0, |n, v, _| (n + 1, 1.0 / v))
797            .unwrap();
798        assert_eq!(
799            numbers(&r, "inv"),
800            [None, Some(1.0), Some(1.0), None, Some(0.5)]
801        );
802        assert!(!r.column("inv").unwrap().is_required());
803    }
804
805    #[test]
806    fn reservoir_picks_rows_evenly() {
807        // Each of 10 rows should be picked in about 3/10 of the runs.
808        let s = series((0..10).collect(), (0..10).map(|i| Some(i as f64)).collect());
809        let mut counts = [0usize; 10];
810        let runs = 20_000;
811        for seed in 0..runs {
812            let picked = s.sample(Sample::Reservoir { size: 3, seed }).unwrap();
813            assert_eq!(picked.len(), 3);
814            let begin = picked.key().begin();
815            assert!(begin.windows(2).all(|w| w[0] < w[1]), "in order");
816            for &t in begin {
817                counts[t as usize] += 1;
818            }
819        }
820        let expected = runs as f64 * 0.3;
821        for c in counts {
822            // Five standard deviations: sqrt(20000 * 0.3 * 0.7) is about 65.
823            assert!((c as f64 - expected).abs() < 5.0 * 65.0, "{counts:?}");
824        }
825        // The same seed picks the same rows.
826        let a = s.sample(Sample::Reservoir { size: 4, seed: 9 }).unwrap();
827        let b = s.sample(Sample::Reservoir { size: 4, seed: 9 }).unwrap();
828        assert_eq!(a.key().begin(), b.key().begin());
829    }
830}