Modelling Sensor Deployment History with Validity Intervals

A validity interval turns a registry row from a statement about the present into a statement about a period: this device sat at this coordinate, with these coefficients, from this instant until that one. Once every mutable fact carries one, joining a reading to its metadata stops being a lookup and becomes an as-of join — find the row whose interval contains the observation time. This page builds that model end to end: the half-open interval convention, the PostgreSQL schema that makes overlaps impossible, the SQL join, and the pandas equivalent for backfills. It is the mechanical core of sensor metadata and device registry management.

Why the Interval Convention Decides Everything Else

Pick the wrong convention and every downstream query inherits an off-by-one that appears only at change boundaries — which is precisely where anyone auditing the data will look.

Half-open means [valid_from, valid_to): the start instant is inside the interval, the end instant is not. When a sensor is moved at 2026-03-14T09:00:00Z, the old deployment’s valid_to and the new deployment’s valid_from are both exactly that instant. A reading at 08:59:59.999 joins to the old row; one at 09:00:00.000 joins to the new one; nothing joins to both and nothing falls between them. Closed-closed intervals (valid_to inclusive) duplicate the boundary instant. Open-closed intervals lose it. Both bugs are rare enough to survive testing and common enough to appear in production.

There is a second reason the convention matters: PostgreSQL’s tstzrange is half-open by default ([)), and so is pandas.merge_asof’s backward direction. Choosing anything else means fighting both tools forever.

The boundary instant belongs to exactly one interval Timeline around a sensor move at 09:00, showing a reading one millisecond before joining the old deployment, the reading exactly at the boundary joining the new one, and neither joining both. The boundary instant belongs to exactly one interval 08:59:59.999 joins deployment A 09:00:00.000 A closes, B opens 09:00:00.000 joins deployment B 09:00:00.001 joins deployment B the instant of the move
Half-open intervals — start inclusive, end exclusive — are what make this unambiguous. Closed-closed intervals duplicate the boundary reading.

Production-Ready Implementation

The schema puts the interval in a range type so the database can enforce non-overlap directly.

-- postgresql 15 · btree_gist extension required
CREATE EXTENSION IF NOT EXISTS btree_gist;

CREATE TABLE deployment (
    deployment_id bigserial PRIMARY KEY,
    device_id     text        NOT NULL,
    site_id       text        NOT NULL,
    latitude      double precision NOT NULL,
    longitude     double precision NOT NULL,
    height_agl_m  real        NOT NULL,
    valid         tstzrange   NOT NULL,
    CONSTRAINT deployment_no_overlap
        EXCLUDE USING gist (device_id WITH =, valid WITH &&)
);

CREATE INDEX deployment_lookup ON deployment USING gist (device_id, valid);

EXCLUDE USING gist (device_id WITH =, valid WITH &&) reads as: no two rows may share a device_id and have overlapping valid ranges. It is one line, and it removes an entire class of data-quality incident — the kind that shows up as a doubled reading count in a monthly report six weeks later.

The as-of join is then a containment test, which the GiST index answers directly:

SELECT r.device_id,
       r.observed_at,
       r.value,
       d.site_id,
       d.latitude,
       d.longitude,
       d.height_agl_m
FROM   reading r
LEFT   JOIN deployment d
       ON  d.device_id = r.device_id
       AND d.valid @> r.observed_at        -- range contains timestamp
WHERE  r.observed_at >= %(since)s
  AND  r.observed_at <  %(until)s;

The LEFT JOIN is deliberate. An inner join makes readings from unregistered devices vanish from the result set with no trace, which is the worst possible handling: the data is neither processed nor reported missing. With a left join, site_id IS NULL is a countable, alertable condition.

For backfills, the pandas equivalent uses merge_asof, which does the same thing in one pass over two sorted frames:

# python 3.11 · pandas==2.2.2
import pandas as pd

def attach_deployment(readings: pd.DataFrame, deployments: pd.DataFrame) -> pd.DataFrame:
    """As-of join readings to the deployment valid at each observation time.

    readings:    device_id, observed_at (UTC, tz-aware), value
    deployments: device_id, valid_from, valid_to (UTC, tz-aware; valid_to NaT = current)
    Returns readings with site_id/latitude/longitude/height_agl_m attached, NaN where
    no deployment covered the observation.
    """
    r = readings.sort_values("observed_at")
    d = deployments.sort_values("valid_from")

    merged = pd.merge_asof(
        r, d,
        left_on="observed_at", right_on="valid_from",
        by="device_id",
        direction="backward",          # the most recent interval that started at or before
        allow_exact_matches=True,      # valid_from is inclusive
    )

    # merge_asof only checks the lower bound; enforce the exclusive upper bound ourselves
    expired = merged["valid_to"].notna() & (merged["observed_at"] >= merged["valid_to"])
    merged.loc[expired, ["site_id", "latitude", "longitude", "height_agl_m"]] = pd.NA
    return merged

The comment on the last two lines is the part people miss. merge_asof matches on the lower bound only — it will happily attach a deployment that ended a year before the reading was taken. The explicit upper-bound mask is what turns a nearest-match into a containment match.

Join styles, and what each one answers Matrix comparing a latest-wins lookup, an as-of join and a full temporal join on reproducibility, behaviour after a sensor moves, and cost. Join styles, and what each one answers Reproducible After a move Cost Latest row wins no rewrites history lowest As-of join (range contains) yes correct index probe merge_asof (batch) yes, with an upper-bound mask correct one sorted pass
merge_asof matches on the lower bound only, so the explicit upper-bound mask is what turns a nearest match into a containment match.

Parameter Tuning Guide

Registry entity Typical rows per device Change trigger Reprocessing impact when corrected
Deployment (location) 1–4 physical move, mast change all readings in the interval
Calibration 4–20 service visit, co-location all readings in the interval
Units / firmware 1–3 firmware update decoder output, then everything
Ownership / project 1–2 contract change reporting only

The right-hand column is the one to plan around. A location correction invalidates every spatial aggregate in its interval; a project change invalidates a report header. Treat them differently in your reprocessing policy rather than rebuilding everything on any registry edit.

Resolution throughput by lookup strategy Bar chart of readings resolved per second for a per-reading database query, a cached in-memory binary search, and a batch merge_asof pass over a sorted frame. Resolution throughput by lookup strategy 0 2000000 4000000 6000000 1400 DB query per reading 2100000 In-memory bisect 5400000 merge_asof (batch) readings / second
Three orders of magnitude separate the first two. The registry belongs in memory, refreshed on a version change rather than consulted per message.

Verification and Testing

Three tests catch nearly every interval bug. The boundary test is the one that matters most.

# python 3.11 · pandas==2.2.2
import pandas as pd

def test_boundary_instant_joins_exactly_once():
    move = pd.Timestamp("2026-03-14T09:00:00Z")
    deployments = pd.DataFrame({
        "device_id": ["s1", "s1"],
        "site_id": ["roof-a", "roof-b"],
        "latitude": [51.50, 51.52], "longitude": [-0.12, -0.10],
        "height_agl_m": [12.0, 18.0],
        "valid_from": [pd.Timestamp("2026-01-01T00:00:00Z"), move],
        "valid_to": [move, pd.NaT],
    })
    readings = pd.DataFrame({
        "device_id": ["s1", "s1", "s1"],
        "observed_at": [move - pd.Timedelta("1ms"), move, move + pd.Timedelta("1ms")],
        "value": [21.0, 21.1, 21.2],
    })

    out = attach_deployment(readings, deployments)
    assert len(out) == 3                              # no duplication at the boundary
    assert list(out["site_id"]) == ["roof-a", "roof-b", "roof-b"]

Alongside it, assert that the database rejects an overlap (insert a deliberately overlapping deployment and expect an ExclusionViolation), and that a reading before any deployment resolves to null rather than to the earliest row. The third is the one merge_asof gets wrong if direction is set to nearest instead of backward — a setting that looks more forgiving and is quietly catastrophic, because it attaches future metadata to past readings.

Gotchas

merge_asof requires both frames sorted by the join key, globally. Sorting within groups is not enough; the function raises if the left frame is unsorted, but silently produces wrong matches if the right frame’s valid_from is not monotonic. Sort both, every time.

Timezone-naive columns will not merge against timezone-aware ones. pandas raises here, which is merciful. The dangerous version is a registry loaded from CSV where valid_from parses as naive and is then localized to the server’s zone rather than UTC — the join succeeds and is wrong by the UTC offset.

A GiST index on (device_id, valid) is not used by an equality-only query. If you also query the deployment table by device_id alone, add a plain B-tree index for that path. The GiST index is for containment, and the planner will not use it to answer WHERE device_id = 's1' efficiently.

Deployments with valid_to in the future. Scheduling a move in advance is reasonable, but the exclusion constraint will then reject the row that closes the current deployment. Close first, insert second, inside one transaction.


FAQ

Why half-open intervals rather than inclusive on both ends?

Because the instant of a change belongs to exactly one interval. With closed-closed intervals, a reading taken at the precise second a sensor was moved matches both the old and the new deployment, and the join silently doubles that row. Half-open — valid_from inclusive, valid_to exclusive — makes the boundary unambiguous and lets the database enforce non-overlap for you.

Can I do this with a simple "latest row wins" query instead?

Only if you never reprocess history, which is not a promise any pipeline keeps. A latest-wins join answers “what is true now”; the question a backfill asks is “what was true then”. They give the same answer until the first time a sensor is moved or recalibrated, and after that the latest-wins query quietly rewrites every historical aggregate.

Is an as-of join fast enough for a streaming path?

The pandas merge_asof version is a batch tool — excellent for backfills, unnecessary for a stream. In the streaming path, hold the registry in memory and binary-search the per-device interval list, which resolves in well under a microsecond. Use merge_asof when you are reprocessing a month of history in one pass.