Skip to content

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) and data_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:

tau = clamp(half_life / 600, 0.5, 3.0)   # slots, at 60-minute resolution

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.