Skip to content

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.

SLOT module-attribute

SLOT = timedelta(minutes=10)

EPOCH module-attribute

EPOCH = datetime(1970, 1, 1, tzinfo=UTC)

Where grids are counted from when a client gives no other origin.

Sample dataclass

Sample(at: datetime, value: T | None)

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.

at instance-attribute

at: datetime

value instance-attribute

value: T | None

__post_init__

__post_init__() -> None

Series dataclass

Series(
    samples: tuple[Sample[T], ...],
    known_until: datetime,
    unit: str | None = None,
)

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.

samples instance-attribute

samples: tuple[Sample[T], ...]

known_until instance-attribute

known_until: datetime

Up to this moment the client vouches that no change went unreported.

unit class-attribute instance-attribute

unit: str | None = None

The values' unit as the source gave it (W, kW, %, ¢/kWh); microgrid.engine.units converts it.

times property

times: tuple[datetime, ...]

Each sample's time.

__post_init__

__post_init__() -> None

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

Interval(start: datetime, end: datetime, value: V)

A value over [start, end): a forecast's mean power, a price.

start instance-attribute

start: datetime

end instance-attribute

end: datetime

value instance-attribute

value: V

__post_init__

__post_init__() -> None

Forecast dataclass

Forecast(
    intervals: tuple[Interval[V], ...],
    issued_at: datetime,
    source: str = "",
)

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.

intervals instance-attribute

intervals: tuple[Interval[V], ...]

issued_at instance-attribute

issued_at: datetime

source class-attribute instance-attribute

source: str = ''

Where it came from, for the record (solcast, emhass:sensor.x).

start property

start: datetime | None

The first interval's start; None when there are no intervals.

end property

end: datetime | None

The last interval's end; None when there are no intervals.

__post_init__

__post_init__() -> None

Price dataclass

Price(p_import: float, p_export: float)

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.

p_import instance-attribute

p_import: float

p_export instance-attribute

p_export: float

__post_init__

__post_init__() -> None

Quantiles dataclass

Quantiles(kw: Mapping[float, float])

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.

kw instance-attribute

kw: Mapping[float, float]

median property

median: float

The 0.5 quantile.

has_lower property

has_lower: bool

Whether a level below the median says something of the downside.

__post_init__

__post_init__() -> None

__hash__

__hash__() -> int

__reduce__

__reduce__() -> tuple[
    type[Quantiles], tuple[dict[float, float]]
]

point classmethod

point(kw: float) -> Quantiles

A point forecast.

at

at(quantile: float) -> float

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

MisalignedError(at: datetime, step: timedelta)

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.

at instance-attribute

at = at

step instance-attribute

step = step

Segment dataclass

Segment(
    start: datetime,
    end: datetime,
    values: Mapping[str, float | None],
    alive: bool,
)

A stretch over which no series changed: each one's value (None where unknown), and whether the path was alive.

start instance-attribute

start: datetime

end instance-attribute

end: datetime

values instance-attribute

values: Mapping[str, float | None]

alive instance-attribute

alive: bool

CounterDelta dataclass

CounterDelta(total: float, falls: int, last_at: datetime)

What a cumulative counter counted since a start.

total instance-attribute

total: float

falls instance-attribute

falls: int

Readings below the highest so far: a counter's drop, held over, not counted from.

last_at instance-attribute

last_at: datetime

The last reading's time.

Composed dataclass

Composed(
    values: tuple[V | None, ...],
    issued_at: datetime | None,
    source: str | None,
)

Each slot's value from the forecasts read, and the forecast in force: the newest.

values instance-attribute

values: tuple[V | None, ...]

issued_at instance-attribute

issued_at: datetime | None

The newest forecast's issue time and source; None when none was issued by then.

source instance-attribute

source: str | None

require_utc

require_utc(*moments: datetime) -> None

Raise unless every moment is timezone-aware UTC.

horizon_slots

horizon_slots(
    now: datetime,
    horizon: timedelta,
    step: timedelta = SLOT,
) -> tuple[datetime, ...]

The published slots: from the slot containing now, exactly horizon long.

slot_floor

slot_floor(
    moment: datetime, step: timedelta = SLOT
) -> datetime

The start (UTC) of the slot containing moment.

slot_starts

slot_starts(
    start: datetime, end: datetime, step: timedelta = SLOT
) -> tuple[datetime, ...]

Slot starts (UTC) from slot_floor(start) up to, not including, end.

price_mean

price_mean(pieces: Sequence[tuple[float, Price]]) -> Price

The weighted mean of prices that tile a slot (align's mean).

quantiles_mean

quantiles_mean(
    pieces: Sequence[tuple[float, Quantiles]],
) -> Quantiles

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

value_at(series: Series[T], at: datetime) -> T | None

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

alive(
    heartbeat: Series[T], limit: timedelta
) -> tuple[tuple[datetime, datetime], ...]

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

counter_delta(
    series: Series[float], start: datetime
) -> CounterDelta

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[float],
    starts: Sequence[datetime],
    step: timedelta,
    *,
    mean: None = None,
) -> tuple[float | None, ...]
align(
    forecast: Forecast[V],
    starts: Sequence[datetime],
    step: timedelta,
    *,
    mean: Mean[V],
) -> tuple[V | None, ...]
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[float]],
    starts: Sequence[datetime],
    step: timedelta,
    *,
    at: datetime,
    max_age: timedelta,
    by_start: bool = True,
    issued_by_at: bool = True,
    mean: None = None,
) -> Composed[float]
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],
) -> Composed[V]
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.