bv.twa
Time-weighted average for irregularly-sampled gauge fields.
Signature
bv.twa(
field: str,
*,
window: str,
where: bv.Col | None = None,
) -> AggDescriptor
Description
bv.twa returns the time-weighted average of a gauge-style field —
the integral of value against arrival time divided by elapsed time.
On each matching event the helper accumulates
sum_v_dt += last_v * (now - last_t) and sum_dt += (now - last_t),
then sets last_v = x, last_t = now. At query time the value is
sum_v_dt / sum_dt (or last_v if only one observation has been
recorded). Time deltas use server processing-time (now_ms() at
arrival) per
project_redis_shaped_no_event_time_ever;
beava intentionally has no event-time concept.
The point of TWA is to handle gauges that are reported at irregular
intervals — CPU utilisation, queue depth, thermostat reading, current
balance — where a plain bv.mean would over-weight whichever sample
was reported most often. bv.twa("cpu_util", window="5m") answers
"what was the time-weighted average CPU utilisation over the last 5
minutes?" — a sample reported once and held for 4 minutes contributes
4× as much as a sample reported and immediately replaced. Use TWA
whenever the time the value was held matters more than the number of
times it was reported.
bv.twa belongs to the decay family (it lives next to EWMA in the
catalogue because both are time-weighted, even though TWA does not
decay — it averages held-time-weighted exactly). Per-event update is
four scalar operations; cost is Tier 1 (~15 ns algorithm floor /
~35 ns measured) and memory is O(1) per entity. Unlike the other
decay ops, bv.twa requires a window= kwarg (not half_life); the
windowing reuses the standard bucket machinery for fixed-horizon TWA,
and window="forever" is allowed for a lifetime TWA per
crates/beava-core/src/register_validate.rs (TWA's lifetime bound is
classified as O1).
Parameters
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
field |
str |
Yes | — | Numeric field (i64 or f64) — the gauge value. |
window |
str |
Yes | — | Duration string matching \d+(ms|s|m|h|d) or "forever". Required (TWA without a horizon would have no defined denominator). |
where |
bv.Col |
No | None |
Boolean expression on event fields; only matching events update the running integral. |
Returns
A single f64 — the time-weighted average. Cold-start (no matching
events) returns null (Python None). After exactly one matching
event the value is the gauge sample itself (no held-time integral yet).
Complexity
| Resource | Bound |
|---|---|
| CPU per event | Tier 1 (~15 ns floor / ~35 ns measured) — see cost-class.md |
| Memory per entity | O(1) — (sum_v_dt, sum_dt, last_v, last_t, initialized) ≈ 40 B |
Lifetime mode (window="forever") |
Allowed — TWA classified as O1 per Phase 12.8 V0-MEM-GOV-02 |
Examples
Example 1: TWA of CPU utilisation per host, 5m window
import beava as bv
@bv.event
class HostMetric:
host_id: str
cpu_util: float
@bv.table(key="host_id")
def HostCpuTwa(metrics) -> bv.Table:
return (
metrics.group_by("host_id")
.agg(cpu_twa_5m=bv.twa("cpu_util", window="5m"))
)
# Push events at irregular intervals
app.push("HostMetric", {"host_id": "node-01", "cpu_util": 0.20})
# 4 minutes of high load reported as one sample at the start...
app.push("HostMetric", {"host_id": "node-01", "cpu_util": 0.95})
# ...then a flurry of low-utilisation samples in the next minute
app.push("HostMetric", {"host_id": "node-01", "cpu_util": 0.10})
app.push("HostMetric", {"host_id": "node-01", "cpu_util": 0.05})
result = app.get("HostCpuTwa", "node-01")
# result == {"cpu_twa_5m": <weighted toward 0.95 because that sample was
# held for 4× longer than the trailing samples>}
Example 2: Lifetime TWA of account balance, only after activation
@bv.table(key="account_id")
def AccountAvgBalance(snapshots) -> bv.Table:
return (
snapshots.group_by("account_id")
.agg(balance_twa=bv.twa("balance",
window="forever",
where=bv.col("activated") == True))
)
Wire
JSON wire form in a register payload:
{
"kind": "derivation",
"name": "HostCpuTwa",
"output_kind": "table",
"key": ["host_id"],
"agg": {
"cpu_twa_5m": {
"op": "twa",
"params": {
"field": "cpu_util",
"window": "5m"
}
}
}
}
See examples/wire/register-fraud-team.request.json for a full payload example.
Edge cases
- Empty stream / cold-start: result is
null. The first matching event seedslast_v = x,last_t = now, with nosum_v_dtcontribution yet (no held-time elapsed). - Single matching event:
sum_dt == 0, so the query returnslast_vdirectly (the sole observation). - Late or duplicate event (Δt ≤ 0):
dt = max(now - last_t, 0); ifdt == 0no integral contribution is added butlast_vis still updated tox(replaces the same-instant gauge value with the newer one). - Missing or non-numeric
field: the event is silently skipped. where=filter excludes the event: no update.- Missing
window=: raisesValueErrorat SDK-helper-call time._validate_window(window, "twa", requires_window=True)enforces it. window="forever": explicitly allowed; the helper integrates over the full lifetime of the entity. Footprint staysO(1)per Phase 12.8 V0-MEM-GOV-02.- No new events for a long time: the held-time integral stops accumulating at
last_tand only resumes on the next matching event. (Likebv.decayed_sum, querying does not mutate state — there is no "decay forward to now" behaviour.) - Cold-entity eviction (
@bv.event(cold_after=...)): drops the underlying state.
See also
- Decay family index — overview of all 6 decay-family ops
- cost-class.md — performance tier (Tier 1)
- bv.mean — arithmetic mean over a fixed window (use this when number of samples matters, not time the value was held)
- bv.ewma — exponentially-weighted moving average (use this when older observations should fade smoothly rather than the current TWA semantics where every sample contributes proportional to its held duration)
- pipeline-dsl/compilation-rules.md — chain compilation rules