Occupancy Forecast¶
How Area Occupancy turns learned history into a forward-looking occupancy probability: the module that computes it, the maths behind every number it returns, and the limits you must know before acting on it.
Why this exists¶
The occupancy_probability sensor answers "is this area occupied right now?". Its lead time is zero: it reacts to evidence as evidence arrives. That is the right answer for lighting, and the wrong one for anything with inertia — a radiator, an underfloor loop, a heat pump — where you must act before occupancy in order to be comfortable at occupancy.
The learned time priors already contain what such a consumer needs: a weekly profile of how often each area is occupied at each hour. data/forecast.py exposes that profile for arbitrary — including future — slots.
Scope: the integration is a pure oracle
The forecast answers exactly one question: P(area occupied at slot T, given everything known now). It deliberately does not choose a horizon, apply a trust gate, or decide when to actuate. Those belong to the consuming control system, which already owns its own control loop; duplicating them here would create two sets of tuning knobs fighting each other.
Note the conditioning. Reporting the unconditional climatology while the integration knows the room is occupied right now would be throwing away information — the oracle is supposed to answer with everything it has.
Module map¶
| File | Role |
|---|---|
data/forecast.py | Forecast maths and service-response shaping. Holds no state |
data/prior.py | Prior — cache accessors all_time_priors(), all_time_prior_points(), prior_for() |
data/analysis.py | calculate_time_priors() — builds the weekly matrix from occupied intervals |
db/queries.py | get_stored_time_priors() (learned slots only), get_all_time_priors() (grid, defaults filled) |
service.py | area_occupancy.get_time_priors — the read-only service entry point |
frontend/area-occupancy-time-priors-card.js | Heatmap card rendering the weekly matrix, served by the integration |
forecast.py is kept separate from prior.py on purpose: exposing the learned priors to other integrations should touch the core probability model as little as possible. prior.py keeps thin cache accessors; the projection maths and the response shape live here.
Public functions¶
forecast_prior(global_prior, slot_time_prior, *, prior_factor) -> float¶
The forecast for a single slot. Combines the area-wide learned prior with the slot's learned time prior and clamps to [MIN_PRIOR, MAX_PRIOR] — mirroring the learned term of Prior.value, minus the configuration floors, which are threshold-relative safety nets for the live estimate and are not meaningful to project onto a future slot. When global_prior is None (never learned) the slot's own bounds-clamped time prior is returned as a best-effort fallback.
persistence_tau_slots(purpose_half_life, slot_minutes) -> float¶
How many slots the current evidence keeps influencing the forecast. See Persistence for why the purpose half-life is remapped rather than used directly.
conditioned_forecast(posterior, slot_forecast, slots_ahead, tau) -> float¶
Blends the live posterior into a future slot's baseline, with the evidence weight decaying as exp(-slots_ahead / tau). Slot 0 returns the posterior unchanged; distant slots return the baseline untouched.
slots_ahead_of(day, slot, now_day, now_slot, slots_per_day) -> int¶
Forward distance from the current slot, wrapping the week. A slot earlier in the week is that slot next week, so the value is never negative and past slots land far enough ahead that the evidence weight vanishes.
build_area_time_priors(area, slot_minutes) -> dict¶
One real area's weekly forecast, as four parallel per-slot maps keyed "day,slot" (day 0=Monday…6=Sunday, slot = hour * 60 // slot_minutes).
build_aggregate_time_priors(members, slot_minutes, area_id, name, *, prior_factor, empirical=None) -> dict | None¶
The same shape for an aggregate zone (the All Areas device, per-floor devices). A zone answers "will anyone be in here", which no average of its rooms can: a room busy at 07:00 and another busy at 19:00 average to a zone that is never busy. So the zone has priors of its own (#557). At the end of each analysis's prior step, compute_zone_priors takes the union of the member rooms' occupied intervals, which is the zone's own occupied history, and runs exactly the room arithmetic on it (compute_slot_priors): a global prior over the observation window, and a prior plus week count per slot.
- Habit (
slots_baseline):forecast_prior(zone global, zone slot), combined like a room's. - Raw (
slots_raw) anddata_points: the zone's own values. - Live (
slots): the higher of the habit and every member's own live-conditioned forecast, so a zone reads busy whenever one of its rooms does. - Before the first analysis after startup: each slot falls back to its busiest member (and the weakest member's
data_points), a lower bound on "anyone".
Returns None when there are no members.
The maths, end to end¶
Step 1 — Occupied intervals¶
Ground truth is motion sensors only, merged and extended by the motion timeout (db/queries.py:get_occupied_intervals). Using a single sensor class keeps the denominator consistent; see Time Prior Flow.
Step 2 — Per-slot ratio¶
For each weekly slot, calculate_time_priors() accumulates occupied seconds and total available seconds over the analysis period, bucketing by Home Assistant local wall-clock time while doing the overlap arithmetic in UTC (so DST transitions neither duplicate nor lose an hour):
time_prior[d,s] = clamp(occupied_seconds[d,s] / total_seconds[d,s],
TIME_PRIOR_MIN_BOUND, TIME_PRIOR_MAX_BOUND)
= clamp(…, 0.03, 0.9)
data_points[d,s] records the number of distinct ISO weeks contributing to that slot — the honest measure of how much evidence is behind the value.
The loop iterates the denominators, not the occupied buckets. A slot the period covered but that saw zero occupancy is a real observation and is stored at the lower bound. Only slots the period never covered stay unwritten.
Step 3 — Unlearned slots¶
An unwritten slot still has to return something. It returns Prior.unlearned_slot_prior — the area's own global_prior.
That choice is not cosmetic. combine_priors(g, g) = g, so a slot filled this way contributes no tilt in either direction. The previous flat 0.5 was neutral only in isolation: blended against a small global prior it came out higher than every genuinely low-occupancy slot, so "never observed" outranked "observed to be empty", and the inflation reached the live sensor as well as the forecast. Before any global prior exists there is nothing neutral to fall back to, so DEFAULT_TIME_PRIOR (0.5) is still used in that one case.
Step 4 — Combination¶
combined = sigmoid((1 - w) · logit(global_prior) + w · logit(time_prior))
w = 0.4 # utils.py:combine_priors, time_weight
forecast = clamp(combined · PRIOR_FACTOR, MIN_PRIOR, MAX_PRIOR)
Dynamic range¶
The blend keeps 60% of the weight on the global prior, so the combined value cannot move far from it. Concretely, sweeping the time prior across its whole legal range:
time_prior | g = 0.05 | g = 0.10 | g = 0.15 | g = 0.25 | g = 0.40 |
|---|---|---|---|---|---|
| 0.03 (min) | 0.041 | 0.062 | 0.081 | 0.114 | 0.163 |
| 0.50 | 0.146 | 0.211 | 0.261 | 0.341 | 0.439 |
| 0.90 (max) | 0.292 | 0.392 | 0.460 | 0.555 | 0.654 |
A low global prior caps the forecast below any useful threshold
For any slot to reach 50%, the area needs global_prior > 0.188. Below that, no hour of the week can ever cross a 50% cutoff no matter how consistently the area is occupied — the ceiling is the transform, not the learning. Rooms occupied a few hours a day routinely sit at global_prior ≈ 0.02–0.13.
This is why the service returns slots_raw. The raw time prior carries the weekly shape without the compression, so it is the right input for ranking hours against each other.
Step 5 — Conditioning on what is happening now¶
Everything above is climatology: the average of past weeks, blind to whether anyone is in the room right now. Occupancy is strongly autocorrelated, so the live posterior is real information about the next few slots:
w = exp(-k / tau) # k = slots ahead of now
slots[k] = sigmoid( w · logit(posterior) + (1 - w) · logit(baseline[k]) )
At k = 0 the result is the posterior itself, so slots[current_slot] equals the area's occupancy_probability sensor exactly — which is what lets a consumer treat this one series as its only source instead of reconciling two.
It cuts both ways, which matters more than the obvious direction: a room that just emptied reads below its habit, so a controller does not pre-heat for a routine that today is not happening.
| posterior 0.98 (occupied) | posterior 0.03 (just emptied) | |
|---|---|---|
| now | 98% | 3% |
| +1 slot | 76% | 16% |
| +2 slots | 51% | 27% |
| +3 slots | 41% | 32% |
| +5 slots | 36% | 35% ← habit |
(Studio, tau = 1, baseline 35%.)
Persistence¶
tau comes from the area's purpose, but not from its half-life directly. Purpose.half_life is an evidence-decay constant in seconds — 45 s for a passageway, 1200 s for a bedroom — describing how fast a motion event stops justifying the live estimate, not how long a room stays occupied. Used as-is it would give tau < 0.35 slots everywhere, collapsing every future slot onto the baseline and making the whole step a no-op.
What the half-life does get right is the ordering: a corridor empties fast, a bedroom does not. So it is remapped monotonically onto a plausible persistence range:
giving passageway 0.5, working 1.0, sleeping 2.0. This is a heuristic, not a measurement, and it is deliberately visible: tau_slots is echoed in the service response so a consumer can see what was assumed. Learning tau per area from the median occupied-run length — data the integration already has — is the principled successor.
Service response¶
area_occupancy.get_time_priors (SupportsResponse.ONLY) returns:
slot_minutes: 60
areas:
Studio:
area_id: studio
global_prior: 0.1294
slot_minutes: 60
current_slot: "6,21" # the slot covering "now" — anchor to the grid
threshold: 0.5 # the area's occupancy threshold
tau_slots: 1.0 # evidence persistence assumed for this area
slots: { "6,21": 0.9848, "6,22": 0.7593, ... } # conditioned: act on this
slots_baseline: { "6,21": 0.3512, "6,22": 0.3390, ... } # stable weekly schedule
slots_raw: { "6,21": 0.6100, "6,22": 0.5800, ... } # learned time prior alone
data_points: { "6,21": 4, "6,22": 4, ... } # weeks of evidence; 0 = unlearned
Piano Terra:
area_id: piano_terra
aggregate: true
members: [salotto, cucina, sala_da_pranzo]
...
Which series do I want?
| Goal | Field |
|---|---|
| Act now — heat if the room is, or is about to be, occupied | slots |
| Program a thermostat schedule that must not move between polls | slots_baseline |
| Rank hours, audit the learned shape | slots_raw |
| Decide whether to trust a slot at all | data_points |
slots is a forecast issued at the moment of the call: two calls a minute apart can differ, by design. slots_baseline only changes when the hourly analysis relearns, which is what a weekly programme needs.
Always check data_points. A slot with 0 is a filled placeholder, not a measurement, and should be skipped or treated as unknown rather than acted upon.
Known limits¶
| Limit | Value | Consequence |
|---|---|---|
| Effective history window | ~28 days (RETENTION_RAW_INTERVALS_DAYS); ~10 days on a fresh install | At most ~4 weekly repetitions per slot |
| Recency weighting | None — flat mean | A change of habits takes ~4 weeks to be absorbed |
| Upper clamp | TIME_PRIOR_MAX_BOUND = 0.9 | A permanently occupied hour saturates; long stuck-on sensors are indistinguishable from real occupancy |
| Period start | max(lookback_start, first_seen) — the earliest interval of any state | Quiet time before the area produced any data is excluded; quiet time after the first interval counts in the denominator (the #520 Bug B fix) |
| Slot resolution | 60 min (DEFAULT_SLOT_MINUTES) | No sub-hour structure |
The nominal lookback is DEFAULT_LOOKBACK_DAYS = 60, but raw intervals older than 28 days are rolled into daily aggregates and deleted, and the forecast reads only raw intervals — so 28 days is the real ceiling.
Lovelace card¶
frontend/area-occupancy-time-priors-card.js renders the weekly matrix as a 7×24 heatmap. It defaults to metric: "live" and scale: "area", so the current slot — outlined as now — and the next one light up while the area is actually occupied, then relax back to habit. The tooltip shows the baseline next to the live value, which is what distinguishes "lit because someone is here" from "lit out of habit". metric: "baseline" and metric: "raw" plot the other two series; the ramp is stretched over the area's own min–max because absolute scaling makes every low-prior room look uniformly cold. Slots with data_points = 0 are hatched as no data and excluded from both the ramp and the comfort-hours total.
Because the live metric moves with the evidence, the card polls every 3 minutes by default rather than the 10 a static matrix would need.
The card ships inside the integration: async_setup serves it from /area_occupancy/frontend/area-occupancy-time-priors-card.js?v=<version> with long cache headers and registers it as an extra frontend module, so it loads on every dashboard and every release is a new cache key. See Time Priors Card for the full option list and how to remove an old manual install.
Related documentation¶
- Time Prior Flow — how the weekly matrix is learned and stored
- Global Prior Flow — the area-wide term
- Services — service reference
- Prior Learning — user-facing overview