Skip to main content

pond_rs/
bycolumn.rs

1//! `by_column` and `rolling_by_column`: reducing rows grouped by the value
2//! of a number column (a distance, a power) instead of by time. A
3//! histogram, per-kilometre splits, time in heart-rate zones, or a band of
4//! percentiles along a route.
5
6use std::ops::Range;
7
8use arrow_array::UInt32Array;
9
10use crate::column::ColumnData;
11use crate::error::{PondError, Result};
12use crate::reducer::{Agg, check_source_kind};
13use crate::rolling::{Alignment, Frame, Span, check_magnitude_in};
14use crate::series::{Column, Key, TimeSeries};
15
16/// How [`TimeSeries::by_column`] cuts a number column into bins (pond-ts
17/// `{ width, origin }` or `{ edges, inclusive }`).
18#[derive(Debug, Clone, PartialEq)]
19pub enum Bins {
20    /// Bins `width` wide with edges at `origin + i * width`: bin `i` holds
21    /// the values in `[origin + i * width, origin + (i + 1) * width)`.
22    /// Every bin from the lowest one holding a value to the highest is
23    /// given, empty ones included.
24    Width {
25        /// Each bin's width.
26        width: f64,
27        /// Where bin 0 starts.
28        origin: f64,
29    },
30    /// Explicit edges `e0 < e1 < … < en`, giving `n` bins, all of them
31    /// given. Bin `i` holds `[e_i, e_i+1)`; with `upper_inclusive` it
32    /// holds `(e_i, e_i+1]` instead (a value on an edge belongs to the bin
33    /// below, as in power and heart-rate zones). Either way the lowest
34    /// edge is in bin 0, so with `upper_inclusive` bin 0 is
35    /// `[e0, e1]`.
36    Edges {
37        /// The bin edges, increasing.
38        edges: Vec<f64>,
39        /// Bins hold their upper edge, not their lower one.
40        upper_inclusive: bool,
41    },
42}
43
44impl Bins {
45    /// Bins `width` wide, edges at whole multiples of `width`.
46    pub fn width(width: f64) -> Self {
47        Bins::Width { width, origin: 0.0 }
48    }
49
50    /// Bins `width` wide, edges at `origin + i * width`.
51    pub fn width_from(width: f64, origin: f64) -> Self {
52        Bins::Width { width, origin }
53    }
54
55    /// Bins between `edges`, each holding its lower edge.
56    pub fn edges(edges: impl Into<Vec<f64>>) -> Self {
57        Bins::Edges {
58            edges: edges.into(),
59            upper_inclusive: false,
60        }
61    }
62
63    /// Bins between `edges`, each holding its upper edge (and bin 0 its
64    /// lower edge too).
65    pub fn edges_upper_inclusive(edges: impl Into<Vec<f64>>) -> Self {
66        Bins::Edges {
67            edges: edges.into(),
68            upper_inclusive: true,
69        }
70    }
71}
72
73/// Columns of equal length without a key: what [`TimeSeries::by_column`]
74/// and [`TimeSeries::rolling_by_column`] give (pond-ts gives an array of
75/// records). Read a column by name, as on a series.
76#[derive(Debug, Clone)]
77pub struct Table {
78    columns: Vec<Column>,
79    rows: usize,
80}
81
82impl Table {
83    /// The number of rows.
84    pub fn len(&self) -> usize {
85        self.rows
86    }
87
88    /// True when there are no rows.
89    pub fn is_empty(&self) -> bool {
90        self.rows == 0
91    }
92
93    /// Every column, in order.
94    pub fn columns(&self) -> &[Column] {
95        &self.columns
96    }
97
98    /// The column called `name`.
99    pub fn column(&self, name: &str) -> Result<&Column> {
100        self.columns
101            .iter()
102            .find(|c| c.name() == name)
103            .ok_or_else(|| PondError::UnknownColumn {
104                call: "Table::column",
105                column: name.to_string(),
106                available: self.columns.iter().map(|c| c.name().to_string()).collect(),
107            })
108    }
109}
110
111/// pond-ts caps a width binning at this many bins.
112const MAX_WIDTH_BINS: f64 = 1_000_000.0;
113
114/// The largest whole number an f64 holds exactly, pond-ts's
115/// `Number.isSafeInteger` bound.
116const MAX_SAFE: f64 = 9_007_199_254_740_991.0;
117
118impl TimeSeries {
119    /// Group the rows into bins by the value of the number column `column`
120    /// and reduce each bin (pond-ts `byColumn(column, bins, mapping)`): a
121    /// histogram, splits per kilometre, or time spent in each power zone.
122    /// One row per bin, in order, with the bin's range as columns `start`
123    /// and `end`, then one column per entry of `aggs`, in the order given.
124    ///
125    /// ```
126    /// use pond_rs::{Agg, Bins, Column, Reducer, TimeSeries};
127    /// let s = TimeSeries::new("ride", vec![0, 1_000, 2_000, 3_000], vec![
128    ///     Column::required("watts", vec![90.0, 150.0, 210.0, 160.0]),
129    /// ])?;
130    /// let zones = s.by_column("watts", &Bins::edges([0.0, 100.0, 200.0, 300.0]),
131    ///     &[Agg::new("rows", "watts", Reducer::Count)])?;
132    /// assert_eq!(zones.column("start")?.numbers()?.values(), &[0.0, 100.0, 200.0]);
133    /// assert_eq!(zones.column("rows")?.numbers()?.values(), &[1.0, 2.0, 1.0]);
134    /// # Ok::<(), pond_rs::PondError>(())
135    /// ```
136    ///
137    /// A row whose value in `column` is a gap, or outside the edges, is in
138    /// no bin. Rows reach a bin's reducers in row order, and each bin is
139    /// reduced as [`TimeSeries::aggregate`] reduces a bucket, with the same
140    /// reducers, kinds and rules; an empty bin gives `count` 0, `sum` 0,
141    /// an empty list for the list reducers, and gaps otherwise. With
142    /// [`Bins::Width`], bins run from the lowest holding a row to the
143    /// highest, so no row at all gives no bins.
144    ///
145    /// Errors, as in pond-ts: `column` not a number column; an output named
146    /// `start` or `end`; a width that is not a positive finite number or
147    /// an origin that is not finite; fewer than two edges, an edge that is
148    /// not finite, or edges not strictly rising; and a width binning that
149    /// gives more than a million bins or bins too narrow for f64 to tell
150    /// their edges apart.
151    pub fn by_column(&self, column: &str, bins: &Bins, aggs: &[Agg]) -> Result<Table> {
152        const CALL: &str = "by_column";
153        let (_, values) = self.number_source(CALL, column)?;
154        let plan = self.value_axis_plan(CALL, aggs, &["start", "end"])?;
155        // Each row's bin, counted from the lowest; NONE for a row in no bin.
156        const NONE: u32 = u32::MAX;
157        let (bin_of, first, count): (Vec<u32>, f64, usize) = match bins {
158            Bins::Edges {
159                edges,
160                upper_inclusive,
161            } => {
162                check_edges(CALL, edges)?;
163                let last = edges.len() - 1;
164                let bin_of = values
165                    .iter()
166                    .map(|&v| {
167                        if !v.is_finite() {
168                            return NONE;
169                        }
170                        edge_bin(edges, v, *upper_inclusive).map_or(NONE, |b| b as u32)
171                    })
172                    .collect();
173                (bin_of, 0.0, last)
174            }
175            &Bins::Width { width, origin } => {
176                if !width.is_finite() || width <= 0.0 {
177                    return Err(invalid(CALL, "width must be a positive finite number"));
178                }
179                if !origin.is_finite() {
180                    return Err(invalid(CALL, "origin must be finite"));
181                }
182                let raw: Vec<f64> = values
183                    .iter()
184                    .map(|&v| {
185                        if v.is_finite() {
186                            width_bin(v, width, origin)
187                        } else {
188                            f64::NAN
189                        }
190                    })
191                    .collect();
192                // The lowest and highest bin. (A bin can be -0 only with
193                // origin 0, where its edges come out the same.)
194                let (mut lo, mut hi) = (f64::INFINITY, f64::NEG_INFINITY);
195                for &b in raw.iter().filter(|b| !b.is_nan()) {
196                    if b < lo {
197                        lo = b;
198                    }
199                    if b > hi {
200                        hi = b;
201                    }
202                }
203                if lo > hi {
204                    (vec![NONE; values.len()], 0.0, 0)
205                } else {
206                    if !(lo.abs() <= MAX_SAFE && hi.abs() <= MAX_SAFE) {
207                        return Err(invalid(
208                            CALL,
209                            "the data range and width give a bin number past 2^53; use a larger width",
210                        ));
211                    }
212                    let count = hi - lo + 1.0;
213                    if count > MAX_WIDTH_BINS {
214                        return Err(invalid(
215                            CALL,
216                            &format!(
217                                "width gives {count} bins (more than {MAX_WIDTH_BINS}); use a larger width or edges"
218                            ),
219                        ));
220                    }
221                    let bin_of = raw
222                        .iter()
223                        .map(|&b| if b.is_nan() { NONE } else { (b - lo) as u32 })
224                        .collect();
225                    (bin_of, lo, count as usize)
226                }
227            }
228        };
229        // Each bin's range, checked as pond-ts checks it.
230        let mut starts = Vec::with_capacity(count);
231        let mut ends = Vec::with_capacity(count);
232        for b in 0..count {
233            let (start, end) = match bins {
234                Bins::Edges { edges, .. } => (edges[b], edges[b + 1]),
235                &Bins::Width { width, origin } => {
236                    let i = first + b as f64;
237                    (origin + i * width, origin + (i + 1.0) * width)
238                }
239            };
240            if !start.is_finite() || !end.is_finite() || end <= start {
241                return Err(invalid(
242                    CALL,
243                    &format!(
244                        "bin [{start}, {end}) is not a range f64 can hold; use a larger width or edges"
245                    ),
246                ));
247            }
248            starts.push(start);
249            ends.push(end);
250        }
251        // The rows of each bin, bin after bin, in row order (a counting
252        // sort).
253        let mut first_row = vec![0u32; count + 1];
254        for &b in bin_of.iter().filter(|&&b| b != NONE) {
255            first_row[b as usize + 1] += 1;
256        }
257        for b in 0..count {
258            first_row[b + 1] += first_row[b];
259        }
260        let mut next = first_row.clone();
261        let mut order = vec![0u32; first_row[count] as usize];
262        for (row, &b) in bin_of.iter().enumerate() {
263            if b != NONE {
264                order[next[b as usize] as usize] = row as u32;
265                next[b as usize] += 1;
266            }
267        }
268        let ranges: Vec<Range<usize>> = (0..count)
269            .map(|b| first_row[b] as usize..first_row[b + 1] as usize)
270            .collect();
271        let (holder, plan) = self.holder(&plan, &order);
272        let plan: Vec<(&Agg, usize)> = plan.iter().map(|&(s, a)| (a, s)).collect();
273        let reduced = holder.reduce_columns(CALL, &plan, &ranges)?;
274        let mut columns = vec![
275            Column::required("start", starts),
276            Column::required("end", ends),
277        ];
278        columns.extend(reduced);
279        Ok(Table {
280            columns,
281            rows: count,
282        })
283    }
284
285    /// For each row, reduce the rows whose value in the number column
286    /// `column` is within `radius` of its own (pond-ts
287    /// `rollingByColumn(column, { radius }, mapping)`): a rolling window
288    /// along a distance or another rising axis instead of time, such as a
289    /// band of speed percentiles along a route. One output row per row of
290    /// this series, with one column per entry of `aggs`, in the order
291    /// given.
292    ///
293    /// ```
294    /// use pond_rs::{Agg, Column, Reducer, TimeSeries};
295    /// let s = TimeSeries::new("run", vec![0, 1_000, 2_000, 3_000], vec![
296    ///     Column::required("km", vec![0.0, 0.4, 1.1, 1.3]),
297    ///     Column::required("pace", vec![300.0, 310.0, 290.0, 280.0]),
298    /// ])?;
299    /// let band = s.rolling_by_column("km", 0.5, &[Agg::new("pace", "pace", Reducer::Avg)])?;
300    /// // Row 1 (0.4 km) sees 0 km and 0.4 km; row 2 (1.1 km) sees 1.1 and 1.3.
301    /// assert_eq!(band.column("pace")?.numbers()?.values(), &[305.0, 305.0, 285.0, 285.0]);
302    /// # Ok::<(), pond_rs::PondError>(())
303    /// ```
304    ///
305    /// The window around a value `x` holds the rows with values in
306    /// `[x - radius, x + radius]`. `column` must never go down, ignoring
307    /// gaps; a row whose value is a gap is in no window and gets each
308    /// reducer's empty value (`count` 0, `sum` 0, an empty list for the
309    /// list reducers, a gap otherwise). Each window is reduced as
310    /// [`TimeSeries::rolling`] reduces a window, with the same reducers,
311    /// kinds and rules. `radius` must be a positive finite number.
312    pub fn rolling_by_column(&self, column: &str, radius: f64, aggs: &[Agg]) -> Result<Table> {
313        self.roll_by_column(column, radius, None, aggs)
314    }
315
316    /// [`TimeSeries::rolling_by_column`] with windows around `centers`
317    /// instead of around each row (pond-ts
318    /// `rollingByColumn(column, { radius, at })`): one output row per
319    /// center, such as the points of a chart's grid. `centers` must be
320    /// finite and never go down; a center with no rows within `radius`
321    /// gets each reducer's empty value.
322    pub fn rolling_by_column_at(
323        &self,
324        column: &str,
325        radius: f64,
326        centers: &[f64],
327        aggs: &[Agg],
328    ) -> Result<Table> {
329        self.roll_by_column(column, radius, Some(centers), aggs)
330    }
331
332    fn roll_by_column(
333        &self,
334        column: &str,
335        radius: f64,
336        centers: Option<&[f64]>,
337        aggs: &[Agg],
338    ) -> Result<Table> {
339        const CALL: &str = "rolling_by_column";
340        let (_, values) = self.number_source(CALL, column)?;
341        if !radius.is_finite() || radius <= 0.0 {
342            return Err(invalid(CALL, "radius must be a positive finite number"));
343        }
344        let plan = self.value_axis_plan(CALL, aggs, &[])?;
345        // The rows with a value, in order; that order must not go down.
346        let mut rows = Vec::with_capacity(values.len());
347        let mut axis = Vec::with_capacity(values.len());
348        for (row, &v) in values.iter().enumerate() {
349            if !v.is_finite() {
350                continue;
351            }
352            if let Some(&prev) = axis.last()
353                && v < prev
354            {
355                return Err(invalid(
356                    CALL,
357                    &format!("column '{column}' must not go down; row {row} ({v}) is below {prev}"),
358                ));
359            }
360            rows.push(row as u32);
361            axis.push(v);
362        }
363        if let Some(centers) = centers {
364            for (k, &c) in centers.iter().enumerate() {
365                if !c.is_finite() {
366                    return Err(invalid(CALL, &format!("center {k} ({c}) is not finite")));
367                }
368                if k > 0 && c < centers[k - 1] {
369                    return Err(invalid(CALL, "centers must not go down"));
370                }
371            }
372        }
373        // Each window as a run of the rows with a value, worked out with
374        // pond-ts's comparisons: in while `value <= center + radius`, out
375        // while `value < center - radius`.
376        let mut windows = Vec::with_capacity(centers.map_or(axis.len(), <[f64]>::len));
377        let (mut lo, mut hi) = (0usize, 0usize);
378        let mut place = |c: f64| {
379            let (from, to) = (c - radius, c + radius);
380            while hi < axis.len() && axis[hi] <= to {
381                hi += 1;
382            }
383            while lo < hi && axis[lo] < from {
384                lo += 1;
385            }
386            lo..hi
387        };
388        match centers {
389            Some(centers) => windows.extend(centers.iter().map(|&c| place(c))),
390            None => windows.extend(axis.iter().map(|&c| place(c))),
391        }
392        // Rows past the last window never enter one, so their values do not
393        // matter (pond-ts never reads them either).
394        rows.truncate(windows.last().map_or(0, |w| w.end));
395        for &(source, agg) in &plan {
396            check_magnitude_in(CALL, &self.columns()[source], agg.reducer, Some(&rows))?;
397        }
398        let (holder, plan) = self.holder(&plan, &rows);
399        let rolled = holder.roll_columns(CALL, &windows_frame(&windows), &plan)?;
400        let columns = if centers.is_none() && rows.len() < values.len() {
401            // Rows without a value take each reducer's empty value, from a
402            // window that holds nothing.
403            let nothing = [Range { start: 0, end: 0 }];
404            let empty = holder.roll_columns(CALL, &windows_frame(&nothing), &plan)?;
405            let mut placed = 0u32;
406            let picks: Vec<(u32, u32)> = values
407                .iter()
408                .map(|v| {
409                    if v.is_finite() {
410                        placed += 1;
411                        (0, placed - 1)
412                    } else {
413                        (1, 0)
414                    }
415                })
416                .collect();
417            rolled
418                .iter()
419                .zip(&empty)
420                .map(|(r, e)| {
421                    let data = ColumnData::gather(&[r.data(), e.data()], &picks);
422                    Column::from_data(r.name().to_string(), data, false)
423                })
424                .collect()
425        } else {
426            rolled
427        };
428        let rows = match centers {
429            Some(_) => windows.len(),
430            None => values.len(),
431        };
432        Ok(Table { columns, rows })
433    }
434
435    /// Check `aggs` for `by_column` / `rolling_by_column` and pair each
436    /// with its source column, in the order given. No two outputs may
437    /// share a name, nor take one of `reserved`.
438    fn value_axis_plan<'a>(
439        &self,
440        call: &'static str,
441        aggs: &'a [Agg],
442        reserved: &[&str],
443    ) -> Result<Vec<(usize, &'a Agg)>> {
444        let mut plan = Vec::with_capacity(aggs.len());
445        for (i, agg) in aggs.iter().enumerate() {
446            let source = self.column_index(call, &agg.source)?;
447            agg.reducer.check(call)?;
448            if reserved.contains(&agg.output.as_str())
449                || aggs[..i].iter().any(|a| a.output == agg.output)
450            {
451                return Err(PondError::DuplicateOutput {
452                    call,
453                    column: agg.output.clone(),
454                });
455            }
456            check_source_kind(call, &self.columns()[source], agg.reducer)?;
457            plan.push((source, agg));
458        }
459        Ok(plan)
460    }
461
462    /// A series of just the `rows` of the columns `plan` reads, and `plan`
463    /// pointed at them.
464    fn holder<'a>(
465        &self,
466        plan: &[(usize, &'a Agg)],
467        rows: &[u32],
468    ) -> (TimeSeries, Vec<(usize, &'a Agg)>) {
469        let indices = UInt32Array::from(rows.to_vec());
470        let mut sources: Vec<usize> = Vec::new();
471        let plan = plan
472            .iter()
473            .map(|&(source, agg)| {
474                let at = sources
475                    .iter()
476                    .position(|&s| s == source)
477                    .unwrap_or_else(|| {
478                        sources.push(source);
479                        sources.len() - 1
480                    });
481                (at, agg)
482            })
483            .collect();
484        let columns = sources
485            .iter()
486            .map(|&s| {
487                let c = &self.columns()[s];
488                Column::from_data(c.name().to_string(), c.data().take_indices(&indices), false)
489            })
490            .collect();
491        let holder = TimeSeries::from_parts(
492            self.name().to_string(),
493            "time".to_string(),
494            Key::Time(vec![0; rows.len()]),
495            columns,
496        );
497        (holder, plan)
498    }
499}
500
501/// A rolling frame of explicit windows, one output row each.
502fn windows_frame(windows: &[Range<usize>]) -> Frame<'_> {
503    Frame {
504        times: &[],
505        span: Span::Windows(windows),
506        alignment: Alignment::default(),
507        min_samples: 0,
508    }
509}
510
511fn invalid(call: &'static str, message: &str) -> PondError {
512    PondError::InvalidData {
513        call,
514        message: message.to_string(),
515    }
516}
517
518fn check_edges(call: &'static str, edges: &[f64]) -> Result<()> {
519    if edges.len() < 2 {
520        return Err(invalid(call, "edges must have at least 2 entries"));
521    }
522    for (i, &e) in edges.iter().enumerate() {
523        if !e.is_finite() {
524            return Err(invalid(call, &format!("edge {i} ({e}) is not finite")));
525        }
526        if i > 0 && e <= edges[i - 1] {
527            return Err(invalid(call, "edges must be strictly rising"));
528        }
529    }
530    Ok(())
531}
532
533/// The bin of `v` among `edges`, or `None` outside them, by pond-ts's
534/// binary search.
535fn edge_bin(edges: &[f64], v: f64, upper_inclusive: bool) -> Option<usize> {
536    let last = edges.len() - 1;
537    let (mut lo, mut hi) = (0usize, last);
538    if upper_inclusive {
539        if v < edges[0] || v > edges[last] {
540            return None;
541        }
542        // The last edge strictly below `v`, so a value on an inner edge
543        // goes to the bin below.
544        while lo < hi {
545            let mid = (lo + hi).div_ceil(2);
546            if edges[mid] < v {
547                lo = mid
548            } else {
549                hi = mid - 1
550            }
551        }
552    } else {
553        if v < edges[0] || v >= edges[last] {
554            return None;
555        }
556        while lo < hi {
557            let mid = (lo + hi).div_ceil(2);
558            if edges[mid] <= v {
559                lo = mid
560            } else {
561                hi = mid - 1
562            }
563        }
564    }
565    Some(lo)
566}
567
568/// The width bin of `v`, as pond-ts works it out: `floor((v - origin) /
569/// width)`, then moved by at most a few steps so that `v` lies in
570/// `[origin + bin * width, origin + (bin + 1) * width)` (division and
571/// multiplication round differently).
572fn width_bin(v: f64, width: f64, origin: f64) -> f64 {
573    let mut bin = ((v - origin) / width).floor();
574    let mut guard = 0;
575    while v < origin + bin * width && guard < 4 {
576        guard += 1;
577        bin -= 1.0;
578    }
579    // The second loop shares the counter, as in pond-ts.
580    while v >= origin + (bin + 1.0) * width && guard < 4 {
581        guard += 1;
582        bin += 1.0;
583    }
584    bin
585}
586
587#[cfg(test)]
588mod tests {
589    use super::*;
590    use crate::Reducer;
591
592    fn track() -> TimeSeries {
593        TimeSeries::new(
594            "t",
595            vec![0, 1000, 2000, 3000],
596            vec![
597                Column::required("d", vec![0.0, 1.0, 2.0, 3.0]),
598                Column::optional("v", [Some(1.0), Some(2e300), None, Some(4.0)]),
599            ],
600        )
601        .unwrap()
602    }
603
604    /// The refusals the parity fixture cannot write (non-finite numbers
605    /// are not JSON) and the ones it leaves out.
606    #[test]
607    fn refuses_what_pond_ts_refuses() {
608        let s = track();
609        let count = [Agg::new("n", "d", Reducer::Count)];
610        for bins in [
611            Bins::width(f64::INFINITY),
612            Bins::width(f64::NAN),
613            Bins::width_from(1.0, f64::NAN),
614            Bins::width_from(1.0, f64::NEG_INFINITY),
615            Bins::edges([0.0, f64::INFINITY]),
616            Bins::edges([f64::NAN, 1.0]),
617            Bins::edges_upper_inclusive([0.0, 0.0]),
618        ] {
619            assert!(s.by_column("d", &bins, &count).is_err(), "{bins:?}");
620        }
621        for radius in [f64::NAN, f64::INFINITY] {
622            assert!(s.rolling_by_column("d", radius, &count).is_err());
623        }
624        for centers in [[0.0, f64::NAN], [f64::NEG_INFINITY, 0.0]] {
625            assert!(s.rolling_by_column_at("d", 1.0, &centers, &count).is_err());
626        }
627        let twice = [
628            Agg::new("n", "d", Reducer::Count),
629            Agg::new("n", "v", Reducer::Count),
630        ];
631        assert!(matches!(
632            s.by_column("d", &Bins::width(1.0), &twice),
633            Err(PondError::DuplicateOutput { .. })
634        ));
635        assert!(matches!(
636            s.rolling_by_column("d", 1.0, &twice),
637            Err(PondError::DuplicateOutput { .. })
638        ));
639        let end = [Agg::new("end", "d", Reducer::Count)];
640        assert!(s.by_column("d", &Bins::width(1.0), &end).is_err());
641        // `start` / `end` are free names when nothing else uses them.
642        assert!(s.rolling_by_column("d", 1.0, &end).is_ok());
643    }
644
645    #[test]
646    fn table_reads_columns_by_name() {
647        let s = track();
648        let t = s
649            .by_column(
650                "d",
651                &Bins::width(2.0),
652                &[Agg::new("n", "v", Reducer::Count)],
653            )
654            .unwrap();
655        assert_eq!((t.len(), t.is_empty()), (2, false));
656        let names: Vec<&str> = t.columns().iter().map(|c| c.name()).collect();
657        assert_eq!(names, ["start", "end", "n"]);
658        assert_eq!(
659            t.column("n").unwrap().numbers().unwrap().values(),
660            &[2.0, 1.0]
661        );
662        assert!(matches!(
663            t.column("nope"),
664            Err(PondError::UnknownColumn { .. })
665        ));
666    }
667
668    /// A value too large for a rolling sum is refused with its row in the
669    /// series, and only when a window reads it.
670    #[test]
671    fn huge_values_refused_only_when_read() {
672        let s = track();
673        let sum = [Agg::new("s", "v", Reducer::Sum)];
674        let err = s.rolling_by_column("d", 0.5, &sum).unwrap_err().to_string();
675        assert!(err.contains("row 1"), "{err}");
676        // Windows around 3 only: rows 0 to 1 still enter (and leave) on the
677        // way, so the value is read.
678        assert!(s.rolling_by_column_at("d", 0.5, &[3.0], &sum).is_err());
679        // A window around 0 only never reaches row 1.
680        let t = s.rolling_by_column_at("d", 0.5, &[0.0], &sum).unwrap();
681        assert_eq!(t.column("s").unwrap().numbers().unwrap().values(), &[1.0]);
682    }
683}