Skip to main content

ValueSeries deep dive

ValueSeries is the value-keyed counterpart of TimeSeries — the third of the core's three series types. Its key is a monotonic non-time axis (cumulative distance, cumulative work, lap number, sample index, strike price, frequency) rather than the wall clock. It carries the ordering-based slice of the series algebra — read the axis, read value columns, find the nearest row, slice a range — and, by type, none of the calendar/clock operators, because a value axis has no wall-clock meaning.

Concept vs. deep dive

This page is the API tour of the ValueSeries type. For when and why you reach for a value axis — the bike-ride "splits line up on distance, not time" intuition, and the closed-vs-projected-out story — read the Value axis concept first. Exhaustive signatures live in the API reference.

Doors in

You never call the constructor directly (it's trusted-construction only). One door projects an existing TimeSeries; the other three construct directly, one per shape the data arrives in.

// 1. PROJECT a TimeSeries onto one of its monotonic columns.
// For data that starts life time-keyed — a ride sampled every second,
// re-keyed by the cumulative-distance column it already carries.
const byDistance = ride.byValue('cumDist'); // TimeSeries → ValueSeries

// 2. CONSTRUCT directly. For data that is NATIVELY value-keyed and never had a
// per-row time key — an options chain keyed by strike, a spectrum keyed by
// frequency. Pick the door that matches what you're holding:
const schema = [
{ name: 'strike', kind: 'value' }, // schema[0] is the axis
{ name: 'iv', kind: 'number' },
{ name: 'oi', kind: 'number' },
] as const;

ValueSeries.fromJSON({ name: 'chain', schema, rows }); // row tuples / objects
ValueSeries.fromColumns({ name: 'chain', schema, columns }); // struct-of-arrays
ValueSeries.fromArrow(table, { axis: 'strike' }); // an Arrow Table

All four share one ingest engine and the same monotonicity contract; they differ only in where the axis comes from and how the payload is laid out. Before the direct doors existed, cross-sectional callers had to launder their axis through a fake time column just to reach TimeSeries.fromColumns and project — the detour is gone.

You haveDoor
A TimeSeries with a monotonic columnseries.byValue(axis)
One record per row (a JSON API, a CSV parse)ValueSeries.fromJSON
One array per column (a columnar wire format)ValueSeries.fromColumns
A decoded Apache Arrow TableValueSeries.fromArrow

byValue(axis) — projection

series.byValue(axis) re-keys a TimeSeries onto one of its numeric columns. The named column becomes the index and is dropped from the value columns — it is the key now. The axis column must be defined, finite, and non-decreasing at every row; byValue validates this and throws otherwise. The usual way to build such a column is scan, a running fold that turns per-sample deltas into a cumulative, monotonic column. See byValue in the aggregation reference.

ValueSeries.fromJSON({ … }) — row ingest

The shape data arrives in from an ordinary JSON API or a CSV parse: one record per row, as tuples aligned with the schema or as objects keyed by column name.

ValueSeries.fromJSON({
name: 'chain',
schema,
rows: [
[90, 0.31, 1200],
[95, 0.28, 3400],
[100, 0.26, 8800],
],
// or: [{ strike: 90, iv: 0.31, oi: 1200 }, …]
});

The TimeSeries.fromJSON contract minus the one thing a value axis has no use for: there is no timestamp parsing and no parse.timeZone. The axis cell must be a finite number — a value axis has no calendar to interpret '2026-01-01' against — so a string axis is an error naming the row, not a silent NaN.

This is the strict door. Every defined cell is checked against its declared kind, so a NaN/Infinity in a number column or a number in a string column is rejected, and a column declared required (the default) rejects a missing cell. null and undefined both mean missing and are fine on a required: false column. The columnar doors below are deliberately looser — they read a non-finite number as a gap and don't check required, because a decoded buffer can't tell "absent" from "not a number".

ValueSeries.fromColumns({ … }) — direct columnar ingest

The exact TimeSeries.fromColumns contract, with the axis in place of time:

  • schema[0] is the 'value'-kind axis column; the rest are value columns. Each columns entry is one column's values, keyed by schema column name and aligned by index.
  • Values may be a plain number[] or a Float64Array (or a string[] for a string column). A cell is a gap (missing) iff it is null/undefined or non-finite — identical for every input type.
  • Float64Array inputs are adopted, not copied (zero-copy): the resulting columns alias your buffers — pass a fresh buffer if that matters. Passing sort: true disables the adoption (a reorder needs its own buffers).
  • Ordering. The axis must be defined, finite, and non-decreasing — it becomes the index. An out-of-order axis throws by default; pass sort: true to stably sort rows by axis value first (the right move for an unordered snapshot, e.g. a keyed live feed that arrives in update order, not axis order).
  • Value columns: number and string, matching TimeSeries.fromColumns.

It throws ValidationError on a non-'value' axis kind, a missing column, a length mismatch, an unsupported value-column kind, or an out-of-order axis without sort; and RangeError on a non-finite axis cell (sorting can't rescue it) or a duplicate column name. See Creating series → Columnar ingest.

ValueSeries.fromArrow(table, { axis }) — Arrow ingest

A decoded Apache Arrow Table — often how a cross-sectional payload arrives in the first place, since an options chain or a spectrum reaching you over Arrow has no time column to key on.

import { tableFromIPC } from 'apache-arrow';

const chain = ValueSeries.fromArrow(tableFromIPC(bytes), { axis: 'strike' });

pond takes no dependency on apache-arrow — bring your own and hand the Table over; the input is duck-typed against the small slice pond reads. Ingest is the zero-copy path: a single-chunk Float64 column's backing Float64Array is adopted as-is, nulls and all (Arrow's validity bitmap is bit-identical to pond's).

  • axis is required. Unlike the time door there is no conventional field name to fall back on — strike, frequency, depth and cumDist are all equally plausible, and keying on the wrong one silently is worse than an error. The axis is read unscaled (an axis carries no TimeUnit); a null in it throws.
  • Value columns: every non-axis field by default, or the subset named by columns (in order). Numeric and Utf8 string columns are supported; any other Arrow type (list/struct/…) throws, naming it.
  • sort: true for an unordered table, which disables the adoption.

Reading a ValueSeries

The operator surface is small and deliberately ordering-based — the part of the algebra that was never about time:

byDistance.length; // number of rows
byDistance.axisName; // 'cumDist' — the key column's name
byDistance.axisValues(); // Float64Array of the axis, in order (zero-copy, read-only)
byDistance.axisAt(3); // the axis value at row 3

byDistance.column('hr'); // a value column → read with .read(i) / .values()

byDistance.nearestIndex(40_000); // row nearest the 40 km mark (binary search)
byDistance.sliceByValue(40_000, 60_000); // the [40 km, 60 km) sub-series (zero-copy)
  • axisValues() returns the live key buffer — zero-copy, so treat it as read-only.
  • column(name) returns the columnar Column (or undefined), for direct .read(i) / .values() reads.
  • nearestIndex(value) is the value-axis cursor primitive: a binary search over the non-decreasing axis. It returns -1 for an empty series and clamps to the first / last row when value falls outside the axis extent.
  • sliceByValue(lo, hi) is the value-axis cull (pan / zoom on a value x): the contiguous sub-series with axis in [lo, hi), zero-copy sliced. lo >= hi (or a range outside the extent) yields an empty series.

Doors out

Every ingest door has a matching export door, so a ValueSeries is never a dead end — fromX(series.toX()) reconstructs the series.

chain.toRows(); // [[90, 0.31, 1200], …] — tuples, gaps as undefined
chain.toObjects(); // [{ strike: 90, iv: 0.31, oi: 1200 }, …]
chain.toJSON(); // { name, schema, rows } — the wire envelope, gaps as null
chain.toJSON({ rowFormat: 'object' }); // …with object rows
chain.toColumns(); // { name, schema, columns } — one array per column
chain.toArrow(); // { length, fields } — Arrow's layout, zero copy
You wantDoorCost
To read rows in JStoRows() / toObjects()One object per row
To send rows over the wiretoJSON()One object per row; JSON.stringify-safe
To send a columnar payload over the wiretoColumns()One array per column — no per-row alloc
To hand the data to another columnar enginetoArrow()A buffer handoff — no per-row work at all

Rows vs columns on the wire. toJSON and toColumns carry the same data in the same envelope shape ({ name, schema, … }); they differ only in the transpose. Prefer toColumns when the consumer is itself column-oriented, or when the payload is dense enough that C arrays beat N×C-element rows on size and parse time — measured at 100k rows × 7 columns, the columnar export allocates 7 arrays where the row exports mint 100 000 objects.

Gaps. toRows / toObjects spell a gap undefined (the JS reading shape); toJSON and toColumns spell it null (the wire shape — NaN isn't JSON, and a Float64Array doesn't stringify as an array).

Round trips are typed, not just documented. ValueSeries.fromColumns(chain.toColumns()) and ValueSeries.fromJSON(chain.toJSON()) compile with no cast — that assignability is the contract, for the number / string columns the ingest engine carries (which is every series built through a direct door).

The one column kind that doesn't come back

A boolean or array-kind column can only reach a ValueSeries by byValue projection — the direct doors take number and string. It exports on every door, but no door takes it back, and the two legs report that differently: fromColumns refuses the payload at compile time (the columnar return type isn't assignable to the ingest type), while toJSON types its boolean cells honestly, so fromJSON accepts the shape and throws at ingest, naming the column and its kind. Drop the column before projecting (series.select(…).byValue(axis)) if you need the round trip.

toArrow() — the zero-copy handoff

toArrow hands back the buffers pond is already holding, in Arrow's memory layout, with no copy — the same { length, fields } shape TimeSeries.toArrow returns (the exporter is shared; a 'value' axis simply exports as a plain float64 field where a time key would come out timestamp). pond takes no dependency on apache-arrow, so the caller assembles the Table:

import { makeData, makeVector, Float64 } from 'apache-arrow';

const { fields } = chain.toArrow();
const f = fields.find((x) => x.name === 'iv')!;
if (f.type !== 'float64') throw new Error(f.type);
const vec = makeVector(
makeData({
type: new Float64(),
length: f.length,
nullCount: f.nullCount,
nullBitmap: f.nullBitmap,
data: f.values,
}),
);

From there another engine — polars, DuckDB, arrow-js — is reachable without a re-ingest. The returned buffers are pond's live storage, not copies: the same read-only contract column(name) and axisValues() already carry. Copy first if the consumer mutates in place.

When not to reach for it

A ValueSeries keeps the full-resolution channel and lets you read/plot it against a value. When you instead want to collapse the axis into per-bin rollups — per-kilometre splits, a histogram, time-in-zone — use byColumn / rollingByColumn, which project out of the algebra to plain bin records rather than returning a series. The Value axis concept walks the split in full.

Where it fits

  • Under the hoodValueSeries wraps the columnar store directly. A value row is an (axis, …values) tuple, not a Time-keyed Event, so it skips the time-only event layer entirely.
  • Plotting@pond-ts/charts accepts a ValueSeries on a linear x scale with a synced value cursor. See the value axis chart reference.
  • Minimal by design — the type carries only the operators a value axis actually needs; the algebra grows as a second value-axis consumer earns it. The ingest / export surface is the exception: it is deliberately at full parity with TimeSeries, because a door you can't come back through isn't a smaller API, it's a trap.