Multi-stream operators#

A stream is values paired with an index that places each event on a shared timeline (a timestamp or sequence number). When streams tick on different clocks, the operators use that index rather than row position. The full model is in Streams, values, and alignment.

screamer has a small set of operators that reshape streams. CombineLatest aligns them on their index; Merge and split interleave and separate them; Dropna removes NaN events and Filter gates events by a mask. The last three change the number of events in the output. All are causal and composable, and each returns results as a (values, index) pair.

import numpy as np
import pandas as pd
from screamer import CombineLatest, Merge, split, Dropna, Filter, GreaterThan, And

# two feeds on one timeline, ticking at different times
a_idx = np.array([1, 3, 5, 7, 9], dtype=np.int64)
a_val = np.array([10., 11., 12., 11., 13.])
b_idx = np.array([2, 4, 6, 8], dtype=np.int64)
b_val = np.array([5., 6., 5.5, 6.5])

Combining streams on different clocks#

CombineLatest: the latest value of each input, paired at every index

Stream a ticks at odd indices, b at even, so they never share a timestamp. CombineLatest emits a row on every distinct index and forward-fills each stream's latest value across the gaps (an as-of join). Watch a value repeat down the rows where only the other stream ticked. With the default emit="when_all", output begins only once every stream has ticked at least once, so there are no cold-start NaNs.

The aligned (T, 2) values feed straight into any function, for example RollingCorr(10)(aligned); inside a Pipeline, the shorter RollingCorr(10)(CombineLatest()(a, b)) form works too.

aligned, idx = CombineLatest()(a_val, b_val, index=[a_idx, b_idx])
spread = aligned[:, 0] - aligned[:, 1]

pd.DataFrame({
    "a (carried)": aligned[:, 0],
    "b (carried)": aligned[:, 1],
    "spread": spread,
}, index=idx).rename_axis("index")
a (carried) b (carried) spread
index
2 10.0 5.0 5.0
3 11.0 5.0 6.0
4 11.0 6.0 5.0
5 12.0 6.0 6.0
6 12.0 5.5 6.5
7 11.0 5.5 5.5
8 11.0 6.5 4.5
9 13.0 6.5 6.5

Emit modes: when_all vs on_any#

emit="on_any" starts at the very first event, filling streams not yet seen with NaN until their first value arrives. when_all (used above) is the same result without those leading NaN rows. Use on_any when you want the earliest possible output and can drop the warm-up NaNs downstream, an idiom shown at the end.

aligned_any, idx_any = CombineLatest(emit="on_any")(a_val, b_val, index=[a_idx, b_idx])
print(f"on_any: {len(idx_any)} rows, when_all: {len(idx)} rows")

pd.DataFrame({
    "a (carried)": aligned_any[:, 0],
    "b (carried)": aligned_any[:, 1],
}, index=idx_any).rename_axis("index")
on_any: 9 rows, when_all: 8 rows
a (carried) b (carried)
index
1 10.0 NaN
2 10.0 5.0
3 11.0 5.0
4 11.0 6.0
5 12.0 6.0
6 12.0 5.5
7 11.0 5.5
8 11.0 6.5
9 13.0 6.5

Interleaving and separating: Merge and split#

Merge: every event from both streams, in index order, tagged by source

Merge interleaves the streams into one index-sorted sequence, tagging each event with the stream it came from. It does not align or forward-fill; it just orders every event on one timeline. split is its inverse: given the tagged stream, it routes the events back into per-source (values, index) pairs.

values, sources, m_idx = Merge()(a_val, b_val, index=[a_idx, b_idx])

pd.DataFrame({
    "value": values,
    "source": np.where(sources == 0, "a", "b"),
}, index=m_idx).rename_axis("index")
value source
index
1 10.0 a
2 5.0 b
3 11.0 a
4 6.0 b
5 12.0 a
6 5.5 b
7 11.0 a
8 6.5 b
9 13.0 a
(a_back, a_back_idx), (b_back, b_back_idx) = split(values, sources, index=m_idx)

print("recovered a:", a_back, "at", a_back_idx)
print("recovered b:", b_back, "at", b_back_idx)
print("round trip matches the originals:",
      np.array_equal(a_back, a_val) and np.array_equal(b_back, b_val))
recovered a: [10. 11. 12. 11. 13.] at [1 3 5 7 9]
recovered b: [5.  6.  5.5 6.5] at [2 4 6 8]
round trip matches the originals: True

Dropping events: Dropna#

Dropna: events whose value is NaN are dropped

Dropna removes events whose value is NaN, giving a shorter, gap-free stream. For a 2-D aligned stream, how="any" (the default) drops a row if any column is NaN, while how="all" drops it only if every column is NaN.

readings_idx = np.array([1, 2, 3, 4, 5], dtype=np.int64)
readings = np.array([1.0, np.nan, 3.0, -4.0, 5.0])

clean, clean_idx = Dropna()(readings, index=readings_idx)
print("input  :", readings, "at", readings_idx)
print("cleaned:", clean, "at", clean_idx, "(index 2 removed)")

# 2-D: how='any' vs how='all'
grid_idx = np.array([1, 2, 3], dtype=np.int64)
grid = np.array([[1.0, np.nan],
                 [np.nan, np.nan],
                 [3.0, 4.0]])
_, keep_any = Dropna(how="any")(grid, index=grid_idx)
_, keep_all = Dropna(how="all")(grid, index=grid_idx)

pd.DataFrame({
    "col 0": grid[:, 0],
    "col 1": grid[:, 1],
    "kept (how=any)": np.isin(grid_idx, keep_any),
    "kept (how=all)": np.isin(grid_idx, keep_all),
}, index=grid_idx).rename_axis("index")
input  : [ 1. nan  3. -4.  5.] at [1 2 3 4 5]
cleaned: [ 1.  3. -4.  5.] at [1 3 4 5] (index 2 removed)
col 0 col 1 kept (how=any) kept (how=all)
index
1 1.0 NaN False True
2 NaN NaN False False
3 3.0 4.0 True True

Comparison and logic operators#

A mask is a stream of 1.0 (keep) and 0.0/NaN (drop). Build one from the comparison and logic operators, which apply element-by-element to any stream.

Below, a range mask keeps values that lie strictly inside (-2.0, 2.0). Two comparison streams are combined with And:

from screamer import (
    LessThan, GreaterEqual, LessEqual, Equal, NotEqual,
    Or, Not, Where, IsNan, IsFinite,
)

x_demo = np.array([0.5, 1.8, -0.3, 2.5, -1.1])
demo_idx = np.arange(len(x_demo), dtype=np.int64)
lo_bound = np.full_like(x_demo, -2.0)
hi_bound = np.full_like(x_demo, 2.0)

mask_demo = And()(GreaterThan()(x_demo, lo_bound), LessThan()(x_demo, hi_bound))
in_range, in_range_idx = Filter()((x_demo, demo_idx), (mask_demo, demo_idx))
print("mask:", mask_demo)
print("kept:", in_range, "at index", in_range_idx)

# Where selects between two streams element-by-element based on a mask.
zero = np.zeros_like(x_demo)
print("Where(mask, x, 0):", Where()(mask_demo, x_demo, zero))
mask: [1. 1. 1. 0. 1.]
kept: [ 0.5  1.8 -0.3 -1.1] at index [0 1 2 4]
Where(mask, x, 0): [ 0.5  1.8 -0.3  0.  -1.1]

Comparison and logic operator reference:

Keeping events: Filter#

Filter: keep the events where the mask is nonzero

Filter takes two streams: a data stream and a mask stream. Each event in the data stream is kept when its aligned mask value is nonzero; a zero or NaN mask drops the event. Build the mask with the comparison and logic operators shown above. For plain NaN removal, Dropna is simpler.

mask = GreaterThan()(readings, np.zeros_like(readings))
kept, kept_idx = Filter()((readings, readings_idx), (mask, readings_idx))
print("kept:", kept, "at index", kept_idx)

pd.DataFrame({
    "value": readings,
    "mask (v > 0)": mask,
    "kept": np.isin(readings_idx, kept_idx),
}, index=readings_idx).rename_axis("index")
kept: [1. 3. 5.] at index [1 3 5]
value mask (v > 0) kept
index
1 1.0 1.0 True
2 NaN NaN False
3 3.0 1.0 True
4 -4.0 0.0 False
5 5.0 1.0 True

Composing them#

The operators chain. A common idiom removes the cold-start NaN that emit="on_any" produces: align with on_any to start as early as possible, then Dropna to drop the warm-up rows, giving a clean aligned stream ready for any function.

raw, raw_idx = CombineLatest(emit="on_any")(a_val, b_val, index=[a_idx, b_idx])
clean, clean_idx = Dropna()(raw, index=raw_idx)

print("on_any rows :", len(raw_idx), "(the first has a NaN)")
print("after dropna:", len(clean_idx), "clean aligned rows")

pd.DataFrame({
    "a": raw[:, 0],
    "b": raw[:, 1],
    "kept (dropna)": ~np.isnan(raw).any(axis=1),
}, index=raw_idx).rename_axis("index")
on_any rows : 9 (the first has a NaN)
after dropna: 8 clean aligned rows
a b kept (dropna)
index
1 10.0 NaN False
2 10.0 5.0 True
3 11.0 5.0 True
4 11.0 6.0 True
5 12.0 6.0 True
6 12.0 5.5 True
7 11.0 5.5 True
8 11.0 6.5 True
9 13.0 6.5 True