Skip to main content

How occupancy is determined

This page explains the model behind the numbers. It is background rather than instruction: if you are trying to get data out, start with the GraphQL quickstart or a guide.

Reading it is worth ten minutes if you are comparing API results against the XY Sense dashboard, or explaining to someone why two numbers that "should" match do not.

From sightings to occupancy

Sensors detect people as anonymous positions on a floor, expressed as XY coordinates. A detection is a sighting. Sightings are the raw material for everything else:

  • Floor sightings expose those positions directly, as a full snapshot of a floor for a moment in time.
  • Occupancy is derived by asking which sightings fall inside the shape of each floor space, then summarising that space's state. Occupancy belongs to a space rather than to any one sensor: see How sensors map to spaces.

Both are produced approximately every two seconds, which is the fastest the API and webhooks will tell you anything.

Occupancy state is a function of time since the last sighting

A space is not simply occupied or empty. Its state is decided by how recently someone was seen in it, relative to collectedDate, across four values with 30-second and 60-second thresholds. The exact definitions are in Available data; what follows is why the model has that shape.

RecentlyOccupied exists because people are not continuously visible. Someone at a desk who leans out of sensor view, or a meeting-room occupant briefly obscured, should not flip a space to empty and back again. The 30-to-60-second band absorbs that, which means a space you would call "in use" reads as either CurrentlyOccupied or RecentlyOccupied depending on the exact moment you sampled it. Treat both as occupied unless you specifically need the distinction.

Two consequences worth designing for:

  • A space can change state with no one entering or leaving it, simply because 30 or 60 seconds elapsed.
  • Headcount and OccupancyStatus change independently. Over 90% of occupancy events are headcount-only changes, where the status is unchanged. See ExcludeHeadcountChanges if you only care about occupied versus vacant.

occupancyStatusChangeDate tells you when the state last changed, and stays put while headcount moves around within the same state. collectedDate is when the observation was made, and is the field to use as the event time.

Why analytics numbers differ from live ones

Analytics is not a rolled-up copy of the live feed. It applies filters that the live feed does not, deliberately, so that reporting reflects real usage rather than raw detections.

The walk-by filter excludes activity with less than one minute of usage over a ten-minute window. Someone crossing a room to reach a window is a genuine sighting, and appears in the live data, but it is not usage of that room and would inflate utilisation figures. Hourly and daily analytics exclude walk-bys; minute and five-minute intervals do not.

Active hours apply to daily results only. Each building has configured active hours, defaulting to 7 am to 7 pm Monday to Friday, and daily aggregates exclude activity outside them. A cleaner moving through a floor at 9 pm should not register as a working day's occupancy.

Missing intervals mean zero. Intervals with no occupancy are omitted from analytics results rather than returned as zero rows, which is a storage optimisation. When you aggregate downstream, treat an absent interval for a known space as zero occupancy, not as missing data. Averaging only the rows you received will overstate utilisation, sometimes badly.

So a live headcount and an analytics figure for the same space and hour can legitimately disagree, and the analytics number is usually the smaller one. Neither is wrong.

Availability follows the same split

Live data is available within seconds. Analytics is processed overnight per building, so the previous day's numbers appear the next morning in the building's local time. Historical heatmaps are the exception among the analytics queries and run close to live.

One thing to keep in mind about occupancyPerMinute: minute resolution does not mean minute latency. It is next-day data at minute granularity. Exact figures are in Limits and retention.

Time is UTC, buildings are not

Timestamps and from/to arguments are UTC, but analytics processing, active hours, and the "available next morning" behaviour are all in the building's local time. A building in Sydney and one in London have their overnight runs and their working day at different absolute times.

That mismatch is the most common reason API results disagree with the dashboard. When they do, check the timezone of your from/to arguments first. See Timestamps are UTC for the general rule and a worked conversion.

Where to go next