microgrid.engine.series¶
Time series: the form a client hands its data in, and the operations every client needs.
Apart from the model's setup, everything a client knows arrives as a time series: a meter's power, the battery's SOC, a price sensor, each forecast as it was issued. This module holds the types and the operations on them, so that no client writes its own holding, staleness or alignment.
A value holds until the next sample. That is how Home Assistant records state,
and how most recorders store it: a value that does not change sends nothing. So the
age of a value says nothing about whether it is current. What can go stale is the
path the samples come by, and Series.known_until says how far the client
vouches for it: up to then, no change went unreported. A stretch the client cannot
vouch for in the middle is a sample whose value is None, from its start (Home
Assistant's unavailable and unknown read the same way).
A forecast is a set of intervals with values, and when it was issued
(Forecast). A forecast that changes over time, as one held in a Home
Assistant entity's attributes does, is a sequence of forecasts; compose
reads them as they stood at a moment.
Every time is UTC, timezone-aware: Python compares and subtracts two aware datetimes
that share a tzinfo by wall clock, which is wrong across a DST change, so a
client converts at its edge. Intervals are half-open, [start, end).
The operations:
value_at: the value in force at a moment.means,signed_means: time-weighted means over a grid of slots.segments: several series swept together, cut wherever any of them changes.alive: where a heartbeat shows the path was up.counter_delta: what a cumulative counter counted, across its resets.align: a forecast's values on a grid of slots.compose: the newest fresh forecast for each slot, of several issued.is_stale,prune: a forecast's freshness, and which to keep.to_forecast: a recorded sensor, such as a price, as a forecast's intervals.
The engine's slot grid: the default slot (SLOT, 10 minutes;
EngineConfig.slot, engine/2's slot_minutes), slot_floor,
slot_starts, a horizon's slots (horizon_slots) and the UTC check every
time passes (require_utc). Every operation takes the slot as an argument: none
assumes 10 minutes, or hourly prices.
Pure: no I/O.
EPOCH
module-attribute
¶
Where grids are counted from when a client gives no other origin.
Sample
dataclass
¶
One value and when it took effect (Home Assistant's last_changed).
None: no value from at until the next sample (unavailable, unknown, or
a stretch the client cannot vouch for). A number that is not finite (a sensor's
nan) is read as no value, too: held, it would spread through every mean.
Series
dataclass
¶
Samples in time order, each value held until the next, known up to known_until.
Equal times are allowed (a recorder can hold two rows at one instant); the last of
them is the one in force. A sample after known_until is refused: the client
cannot have seen it and not vouch for the time before it.
known_until
instance-attribute
¶
Up to this moment the client vouches that no change went unreported.
unit
class-attribute
instance-attribute
¶
The values' unit as the source gave it (W, kW, %, ¢/kWh);
microgrid.engine.units converts it.
of
classmethod
¶
of(
points: Iterable[tuple[datetime, T | None]],
*,
known_until: datetime,
unit: str | None = None,
) -> Series[T]
A series from (time, value) pairs, in time order.
Interval
dataclass
¶
Forecast
dataclass
¶
Intervals in time order, not overlapping, and when the forecast was issued.
A gap between intervals is unknown, never an error, as is time past the last.
Price
dataclass
¶
What one more kWh imported costs, and one more exported earns, over an interval:
the client's currency per kWh (microgrid.engine.units converts per-MWh
prices). Either may be negative; neither may be other than finite.
Quantiles
dataclass
¶
A forecast's values at its quantiles, as {quantile: value}: any levels in
(0, 1) that include the median, such as Solcast's {0.1: kW, 0.5: kW, 0.9: kW}, a
forecaster's deciles, or a point forecast's {0.5: kW}.
Between its levels a value is read linearly (at); beyond the outermost, the
outermost segment extends, on a side where the forecast has a level other than the
median; on a side where it has none, the median holds, as nothing is known of the
spread there. A forecast with no level below the median (has_lower) leaves the
pessimistic case at the median, and a valuation on it names pv_spread.
has_lower
property
¶
Whether a level below the median says something of the downside.
at
¶
The value at quantile: a level's own value; between levels, linear in the
values sorted (a forecaster's quantiles can cross); beyond them, the outermost
segment extended where the forecast has a level on that side of the median, else
the median (the class's docstring).
MisalignedError
¶
Bases: ValueError
A forecast's interval crosses a slot's edge without holding the whole slot: the source neither holds over whole slots nor tiles them, so no value can be given without guessing. Such a source needs another slot length.
Segment
dataclass
¶
CounterDelta
dataclass
¶
Composed
dataclass
¶
Each slot's value from the forecasts read, and the forecast in force: the newest.
require_utc
¶
Raise unless every moment is timezone-aware UTC.
horizon_slots
¶
The published slots: from the slot containing now, exactly horizon long.
slot_floor
¶
The start (UTC) of the slot containing moment.
slot_starts
¶
Slot starts (UTC) from slot_floor(start) up to, not including, end.
price_mean
¶
The weighted mean of prices that tile a slot (align's mean).
quantiles_mean
¶
The weighted mean, quantile by quantile, of forecasts that tile a slot. Each must have the same quantiles. (A mean of quantiles is not the quantile of a mean; over the minutes of one slot the difference is small.)
value_at
¶
The value in force at at: the last sample at or before it. None when there is
none, when it is None, or when at is past known_until.
means
¶
means(
series: Series[float],
starts: Sequence[datetime],
step: timedelta,
*,
max_gap: timedelta | None = None,
stale_nonzero: timedelta | None = None,
) -> tuple[float | None, ...]
The time-weighted mean of each slot [start, start + step).
A plain average of samples over-weights busy stretches; a series that writes only on change (a meter at 0 for days) is badly biased by one. So each slot integrates the held values over time.
A slot is None when:
- no sample is in force at its start, or it ends after
known_until; - a sample with no value is in force within it;
- with
max_gap, a silence longer than that overlaps it (a path that went down without saying so); - with
stale_nonzero, a value other than 0 stays silent longer than that: for a meter that writes often while it draws and once at 0 when it stops, a lost 0 must not hold a draw.
signed_means
¶
signed_means(
series: Series[float],
starts: Sequence[datetime],
step: timedelta,
) -> tuple[
tuple[float | None, ...], tuple[float | None, ...]
]
Each slot's time-weighted mean of the positive part of a signed series, and of its negative part as a positive number: a grid meter's import and export, kept apart where one slot holds both.
alive
¶
Where a heartbeat shows the path alive: within limit after each of its samples,
merged. Every sample counts, whatever its value: it arrived, so the path was up.
segments
¶
segments(
series: Mapping[str, Series[float]],
cuts: Sequence[datetime],
*,
alive_spans: Sequence[tuple[datetime, datetime]]
| None = None,
stale_nonzero: Mapping[str, timedelta] | None = None,
) -> Iterator[Segment]
Sweep several series together from cuts[0] to cuts[-1], one segment per
stretch over which none of them changes.
A segment ends at every cut (a slot's edge, say), at every sample, at each edge of
alive_spans and at each series' known_until. A series' value is the sample
in force at the segment's start; None before its first sample, from its
known_until on, or, for a name in stale_nonzero, once a value other than 0
has been silent that long. Without alive_spans the path is taken as alive.
Segments come one at a time, so a consumer that sums them (a ledger over a year) keeps none; the series themselves, and their sample times, are held. The arguments are checked when it is called, not when the first segment is taken.
counter_delta
¶
What a cumulative counter (an energy meter's total) counted since start: its
rises above the highest reading so far, from the last reading at or before
start. A reading below that is held over, not counted from, so a counter that
drops to zero and recovers counts nothing twice. Samples without a value are
skipped. Of two readings at one instant, the later in the series is the later
reading. Raises when no reading is at or before start.
align
¶
align(
forecast: Forecast[V],
starts: Sequence[datetime],
step: timedelta,
*,
mean: Mean[V] | None = None,
) -> tuple[V | None, ...]
The forecast's value in each slot [start, start + step).
- An interval that holds the whole slot gives its value: an hourly price, or a half-hourly forecast, over 10-minute slots.
- Intervals that tile the slot (each wholly inside it, together covering it)
give their mean, weighted by length: 5-minute prices over 10-minute slots. Values
that are not numbers need
mean, given(weight, value)pairs whose weights sum to 1. - A slot not wholly covered (before the first interval, in a gap, past the end) is None: unknown, never guessed.
- An interval that crosses the slot's edge without holding the whole slot
(15-minute prices over 10-minute slots) raises
MisalignedError, whether or not the slot is otherwise covered: that source needs another slot length.
is_stale
¶
is_stale(
forecast: Forecast[V],
at: datetime,
max_age: timedelta,
*,
by_start: bool = True,
) -> bool
Whether forecast is too old to use at at: issued more than max_age
before it, or empty. With by_start, also when its first interval starts more
than max_age before it: a source that republishes an old prediction keeps its
issue time fresh. Without, for a source whose forecast reaches back to the start of
its day (Solcast's), only the issue time counts.
compose
¶
compose(
issues: Sequence[Forecast[V]],
starts: Sequence[datetime],
step: timedelta,
*,
at: datetime,
max_age: timedelta,
by_start: bool = True,
issued_by_at: bool = True,
mean: Mean[V] | None = None,
) -> Composed[V]
The forecasts issued by at, on the slots, as they stood at at: each slot
takes the newest forecast that covers it, of those not stale (is_stale,
with by_start).
A source whose newest forecast starts after the slot under way (one that publishes
from the next half hour) still covers that slot from an earlier forecast. A slot
none covers is None. When the newest is stale, every slot is None: a stale input is
never reused. A forecast issued after at is never read, so a replay cannot look
ahead; with none issued by then, every slot is None. A caller that has already kept
only the forecasts it had received by at passes issued_by_at=False: a
forecast received by then counts, though its source's clock stamps it a moment
later.
prune
¶
prune(
issues: Sequence[Forecast[V]],
at: datetime,
max_age: timedelta,
) -> tuple[Forecast[V], ...]
The forecasts worth keeping at at, oldest first: one per issue time (the first
given: one read again does not replace it), less every stale one but the newest,
whose staleness a valuation must still report (compose). Bounded by the
age limit.
to_forecast
¶
to_forecast(
series: Series[float],
start: datetime,
end: datetime,
step: timedelta,
*,
issued_at: datetime,
source: str = "",
origin: datetime = EPOCH,
) -> Forecast[float]
A recorded sensor (a price, say) as intervals of step, counted from origin,
over the intervals that overlap [start, end).
A sample applies from the start of the interval it falls in: a price recorded a few
seconds after the hour is that hour's price. Of several in one interval, the last
in time wins. A value holds until the next. An interval with no value in force, or
starting at or after known_until, is left out: a gap. An interval under way at
known_until is given the value in force by then; a later sample within it, not
yet seen, would replace it.