Partitions
Data often mixes several sources in one table: readings from many hosts,
prices for many symbols. Most operations should then look at each source on
its own. A diff on a table of interleaved hosts would subtract host b's
reading from host a's, which means nothing.
partition_by(&["host"]) splits the series by the values of one or more
columns. Operations on the result run on each partition separately, and
collect() puts the rows back into one series, in time order.
This page is about a series you already have. For events that arrive one at a time, a live series splits the same way; see Live series: partitions.
use pond_rs::{Column, TimeSeries};
fn main() -> Result<(), pond_rs::PondError> {
let s = TimeSeries::new(
"cpu",
vec![0, 0, 1_000, 1_000],
vec![
Column::required("host", vec!["a", "b", "a", "b"]),
Column::required("load", vec![1.0, 10.0, 3.0, 40.0]),
],
)?;
// Without partitions: each row minus the row directly above it.
let mixed = s.diff(&["load"])?;
// Row 2 is host a minus host b: a meaningless number.
assert_eq!(mixed.column("load")?.numbers()?.get(2), Some(-7.0));
// With partitions: each host against its own previous reading.
let per_host = s
.partition_by(&["host"])?
.diff(&["load"])?
.collect()?;
assert_eq!(per_host.column("load")?.numbers()?.get(2), Some(2.0)); // a: 3 - 1
assert_eq!(per_host.column("load")?.numbers()?.get(3), Some(30.0)); // b: 40 - 10
Ok(())
}
What runs per partition
A PartitionedSeries has rolling (and rolling_with, rolling_rows,
rolling_over), aggregate, diff, rate,
pct_change, shift, cumulative, fill, fill_all, align,
materialize, dedupe, smooth, baseline, baseline_named,
outliers, scan, scan_to and sample. Each returns
another PartitionedSeries, so they chain. For anything else, map runs
your own closure on each partition and stays partitioned; apply runs it
and collects in one step.
rolling and aggregate keep the partition columns in their output (as a
first of each bucket or window), so the result is still split by host.
One catch, as in pond-ts: a row that min_samples leaves without a value
gets a gap in the partition columns too, so if you split the result again
it lands in the partition with no value.
use pond_rs::{Agg, Column, Reducer, Sequence, TimeSeries};
fn main() -> Result<(), pond_rs::PondError> {
let minute = 60_000;
let s = TimeSeries::new(
"cpu",
vec![0, 0, 30_000, minute, minute],
vec![
Column::required("host", vec!["a", "b", "a", "a", "b"]),
Column::required("load", vec![0.2, 0.9, 0.4, 0.6, 0.7]),
],
)?;
// Per host, per minute: the average load, then only the busy minutes.
let busy = s
.partition_by(&["host"])?
.aggregate(
&Sequence::every("1m")?,
&[Agg::new("avg", "load", Reducer::Avg)],
)?
.apply(|one_host| {
one_host.filter(|r| Ok(r.number("avg")?.is_some_and(|v| v > 0.5)))
})?;
// b at minute 0 (0.9), a at minute 1 (0.6), b at minute 1 (0.7).
assert_eq!(busy.len(), 3);
let hosts: Vec<_> = busy.column("host")?.strings()?.iter().collect();
assert_eq!(hosts, [Some("b"), Some("a"), Some("b")]);
Ok(())
}
Looking at one partition
iter() walks the partitions with their keys, get(&key) finds one, and
group_by(&cols) (or into_groups()) gives owned (key, series) pairs.
A key has one value per partition column, as Option<Value>; None means
the rows with a gap there, which form a partition of their own.
use pond_rs::{Column, TimeSeries, Value};
fn main() -> Result<(), pond_rs::PondError> {
let s = TimeSeries::new(
"trades",
vec![0, 1, 2, 3],
vec![
Column::optional(
"symbol",
vec![Some("AAPL"), Some("MSFT"), None, Some("AAPL")],
),
Column::required("qty", vec![10.0, 5.0, 1.0, 20.0]),
],
)?;
let by_symbol = s.partition_by(&["symbol"])?;
assert_eq!(by_symbol.len(), 3); // AAPL, MSFT, and the row with no symbol
let aapl = by_symbol
.get(&[Some(Value::String("AAPL".into()))])
.unwrap();
assert_eq!(aapl.len(), 2);
for (key, rows) in by_symbol.iter() {
// `as_str` reads a text key part; `as_f64` and `as_bool` the others.
let name = key[0].as_ref().and_then(Value::as_str).unwrap_or("(none)");
println!("{name}: {} rows", rows.len());
}
Ok(())
}
Partitions are listed in the order their first row appears. Several
columns work too: partition_by(&["region", "host"]). A list column
cannot be a partition column; that is an error.
The latest state of each partition, as a table
A common question is "for each symbol, what does the last hour look like,
ranked". The answer is one row per symbol, sorted by a score, which is not
a time series (a series is always in time order with its key first). So
build it in two steps: tail and reduce on each partition give the
numbers, and the table is plain Rust, or an Arrow RecordBatch (pond-rs
re-exports arrow_array and arrow_ipc) if you want to write it to a file.
use pond_rs::arrow_array::{ArrayRef, Float64Array, RecordBatch, StringArray};
use pond_rs::{Agg, Column, Reducer, TimeSeries, Value};
use std::sync::Arc;
fn main() -> Result<(), pond_rs::PondError> {
let min = 60_000;
let bars = TimeSeries::new(
"bars",
vec![0, 0, min, min, 2 * min, 2 * min],
vec![
Column::required("symbol", vec!["A", "B", "A", "B", "A", "B"]),
Column::required("close", vec![10.0, 50.0, 11.0, 50.0, 14.0, 49.0]),
],
)?;
// Score: how far the last close is from the mean of the last 2 minutes.
let mut scores: Vec<(String, f64)> = Vec::new();
for (key, part) in bars.partition_by(&["symbol"])?.iter() {
let symbol = key[0].as_ref().and_then(Value::as_str).unwrap_or("");
let last = part.tail("2m")?.reduce(&[
Agg::new("mean", "close", Reducer::Avg),
Agg::new("close", "close", Reducer::Last),
])?;
if let (Some(mean), Some(close)) = (last.number("mean")?, last.number("close")?) {
scores.push((symbol.to_string(), close - mean));
}
}
scores.sort_by(|a, b| b.1.abs().total_cmp(&a.1.abs())); // biggest first
// A: rows after 0 are 11 and 14, mean 12.5, last 14, score 1.5.
assert_eq!(scores[0], ("A".to_string(), 1.5));
assert_eq!(scores[1], ("B".to_string(), -0.5));
let table = RecordBatch::try_from_iter([
(
"symbol",
Arc::new(StringArray::from_iter_values(scores.iter().map(|s| &s.0))) as ArrayRef,
),
(
"score",
Arc::new(scores.iter().map(|s| s.1).collect::<Float64Array>()) as ArrayRef,
),
])
.expect("columns of one length");
assert_eq!(table.num_rows(), 2);
Ok(())
}
Running rolling over each partition and reading its last row looks at
the same rows, but computes a window at every row of the history to use
one.
Threads
With the parallel feature (on by default), partitions run on several
cores. Each partition is still computed start to finish by one thread, so
the answer is the same, to the bit, however many threads run. On 500
symbols × 1,000 one-minute bars, a rolling one-hour mean, standard deviation
and last value per symbol, collected, took 68 ms on 4 threads, 143 ms on 1,
and 992 ms in pond-ts. More on Performance.