H3 Hexagonal Binning for Real-Time Sensor Density Maps

A live density map answers the question a scatter of raw sensor points cannot: how much coverage, and how much pollution, is concentrated here versus there? H3 hexagonal binning produces that map by assigning every streaming event a stable grid-cell index, then aggregating per cell per time window. The h3 Python library’s geo_to_h3 is a constant-time arithmetic projection — no spatial index, no polygon test — so it keeps pace with high-rate feeds where per-event geometry would not. Hexagons matter specifically because they tile the globe with near-equal area and uniform adjacency, so a density gradient reads the same in every direction, unlike a square lat/lon grid that distorts toward the poles. This page builds a self-contained binning and per-cell rollup function and covers the two decisions that make or break a density map: resolution choice and hot-cell handling. It is the density-grid half of the spatial windowing and geofencing stage, paired with zone-based geofencing.


Why Resolution Is the Only Decision That Matters

Every other parameter of a density map follows from resolution. H3 defines 16 resolutions (0 through 15); each finer level subdivides a cell into roughly seven children, so cell area shrinks by about 7× per step. Resolution 8 gives hexagons with an edge length near 460 m; resolution 9 near 175 m; resolution 7 near 1.2 km.

The tension is statistical, not geometric. Too coarse and distinct hotspots blur together — a resolution-6 cell can swallow an entire district, averaging a fenceline exceedance into the surrounding clean air. Too fine and most cells hold a single sensor, so the “density” surface degenerates into that one sensor’s raw reading dressed up as an aggregate, with no smoothing and enormous cell-to-cell variance. The right resolution is the coarsest one at which most occupied cells still contain two or more sensors: fine enough to localize, dense enough that each cell’s mean is an average of independent measurements rather than a copy of one.

Because resolution is hierarchical, you are not locked in. Cells at resolution 9 aggregate cleanly into their resolution-8 parents with h3_to_parent, so you can bin fine and roll up coarse for different map zoom levels from the same stream — but only if every producer agrees on the base resolution. A backfill that bins at a different resolution yields cell ids that will never join the live feed.

There is a second reason hexagons beat a square grid that only shows up once you start smoothing. Neighbourhood operations — filling a sparse cell from its surroundings, computing a local density gradient, or spreading an alert to adjacent areas — all depend on “what is next to this cell.” In a hexagonal grid every neighbour is equivalent: six of them, each sharing a full edge at the same centre-to-centre distance. h3.k_ring(cell, 1) returns exactly those six, and a gradient computed over them is isotropic. A square grid forces a choice nobody wants to make: four edge-neighbours at distance d and four corner-neighbours at distance d√2, so a smoothing kernel either ignores the diagonals or weights them by an awkward correction factor. That anisotropy leaks into the map as directional artefacts along the grid axes — exactly the kind of structure a reader will mistake for a real pollution plume.

Cell area near-equality is the other half of the argument. H3 cells vary only modestly in area within a resolution, so an event count per cell is a defensible proxy for density. A lat/lon square grid does not have this property: its cells shrink toward the poles, so a fixed count means a higher density at high latitude than at the equator. For a network spanning any meaningful range of latitude, that turns a raw count heatmap into a systematically distorted one.


Production-Ready Implementation

The function below bins a batch of events, aggregates per cell, and attaches each cell’s centroid so the result is immediately mappable and ready for spatial interpolation. It is stateless per batch, which makes it safe to run per closed time window.

# python 3.11 · h3==3.7.6 · pandas==2.2.2 · numpy==1.26.4
from __future__ import annotations
import math
import h3
import numpy as np
import pandas as pd


def bin_density(
    df: pd.DataFrame,
    resolution: int = 8,
    lat_col: str = "latitude",
    lon_col: str = "longitude",
    value_col: str = "metric_value",
    sensor_col: str = "sensor_id",
) -> pd.DataFrame:
    """Bin sensor events into H3 cells and aggregate a density surface.

    Parameters
    ----------
    df : pd.DataFrame
        One row per event; must carry lat, lon, value, and sensor id columns.
    resolution : int
        H3 resolution 0-15. 8 (~460 m edge) is a city-scale default.
    Returns
    -------
    pd.DataFrame
        One row per occupied H3 cell with event_count, mean/p95 value,
        unique active sensors, and the cell centroid (cell_lat, cell_lon).
    """
    if not 0 <= resolution <= 15:
        raise ValueError(f"H3 resolution must be 0-15, got {resolution}")

    frame = df.copy()

    # Guard coordinates: NaN/Inf/out-of-range rows cannot be binned.
    finite = np.isfinite(frame[lat_col]) & np.isfinite(frame[lon_col])
    in_range = (
        frame[lat_col].between(-90, 90) & frame[lon_col].between(-180, 180)
    )
    frame = frame[finite & in_range]
    if frame.empty:
        return pd.DataFrame(
            columns=["h3_cell", "event_count", "mean_value",
                     "p95_value", "active_sensors", "cell_lat", "cell_lon"]
        )

    # O(1) per event — pure arithmetic projection, no lookup.
    frame["h3_cell"] = [
        h3.geo_to_h3(lat, lon, resolution)
        for lat, lon in zip(frame[lat_col], frame[lon_col])
    ]

    rollup = (
        frame.groupby("h3_cell")
        .agg(
            event_count=(value_col, "size"),
            mean_value=(value_col, "mean"),
            p95_value=(value_col, lambda s: float(np.nanpercentile(s, 95))),
            active_sensors=(sensor_col, "nunique"),
        )
        .reset_index()
    )

    # Attach centroids for mapping / interpolation anchors (inverse of geo_to_h3).
    centroids = rollup["h3_cell"].map(h3.h3_to_geo)  # -> (lat, lon) tuples
    rollup["cell_lat"] = [c[0] for c in centroids]
    rollup["cell_lon"] = [c[1] for c in centroids]
    return rollup

A short run shows the shape of the output:

events = pd.DataFrame({
    "sensor_id": ["a", "b", "c", "a", "b"],
    "latitude":  [51.500, 51.5005, 51.501, 40.0, 51.500],
    "longitude": [-0.100, -0.1002, -0.099, -3.0, -0.100],
    "metric_value": [12.0, 14.0, 60.0, 8.0, 13.0],
})
density = bin_density(events, resolution=8)
print(density[["h3_cell", "event_count", "mean_value", "active_sensors"]])

The three London readings collapse into one resolution-8 cell (active_sensors reflecting the unique devices there), the Madrid reading lands in its own cell, and each row carries a centroid ready to hand to spatial interpolation with kriging and IDW.


Parameter Tuning Guide

Resolution is the lever. Match it to sensor spacing so cells stay populated:

Resolution Approx. edge length Approx. cell area Use case
6 ~3.2 km ~36 km² National / regional overview
7 ~1.2 km ~5.2 km² Metro-area air quality
8 ~460 m ~0.74 km² City-scale PM2.5, noise density
9 ~175 m ~0.11 km² Dense urban, campus networks
10 ~65 m ~0.015 km² Industrial site, fenceline

Set the resolution one step coarser than the level at which cells start going single-sensor. If a resolution-9 map shows most cells with active_sensors == 1, drop to 8. For multi-zoom dashboards, bin at your finest useful resolution and precompute coarser levels with h3.h3_to_parent(cell, coarser_res) rather than re-binning the raw stream at each zoom.


Verification and Testing

The properties worth pinning are that co-located points share a cell, that the projection is deterministic, and that a cell round-trips to a centroid inside itself.

import math
import h3
import pandas as pd


def test_colocated_points_share_a_cell():
    df = pd.DataFrame({
        "sensor_id": ["a", "b"],
        "latitude": [51.5000, 51.5003],   # ~30 m apart, well inside one res-8 cell
        "longitude": [-0.1000, -0.1001],
        "metric_value": [10.0, 20.0],
    })
    out = bin_density(df, resolution=8)
    assert len(out) == 1
    assert out.loc[0, "event_count"] == 2
    assert out.loc[0, "active_sensors"] == 2
    assert out.loc[0, "mean_value"] == 15.0


def test_binning_is_deterministic():
    cell1 = h3.geo_to_h3(51.5, -0.1, 8)
    cell2 = h3.geo_to_h3(51.5, -0.1, 8)
    assert cell1 == cell2


def test_centroid_falls_inside_its_own_cell():
    cell = h3.geo_to_h3(51.5, -0.1, 8)
    lat, lon = h3.h3_to_geo(cell)
    assert h3.geo_to_h3(lat, lon, 8) == cell


def test_invalid_coordinates_are_dropped():
    df = pd.DataFrame({
        "sensor_id": ["a", "b"],
        "latitude": [math.nan, 51.5],
        "longitude": [-0.1, -0.1],
        "metric_value": [1.0, 2.0],
    })
    out = bin_density(df, resolution=8)
    assert out["event_count"].sum() == 1

The centroid round-trip test guards the single most useful H3 invariant: h3_to_geo of a cell, re-binned, must return that same cell. If it ever fails, a version mismatch between the library that produced your stored cell ids and the one reading them is the first thing to check.


Gotchas

Mismatched resolutions never join. Two components binning at different resolutions produce disjoint cell-id spaces. A live feed at resolution 8 and a backfill at resolution 9 will silently share zero cells. Pin resolution in config and stamp it into every rollup row.

Single-sensor cells are not a density surface. At too fine a resolution most cells hold one sensor, so mean_value is just that sensor’s reading and cell-to-cell variance explodes. Flag active_sensors == 1 cells as low-confidence before mapping or interpolating them.

Hot-cell skew during incidents. When you key a stream on cell id, a cell whose sensors burst-transmit during a wildfire or spill saturates one worker while the rest idle. Track per-cell counts against the median, then salt the hot cell across k sub-keys or pre-aggregate it locally before the keyed stage — the same fan-out discipline the spatial windowing stage applies to zone keys.

Pentagon cells exist. H3 has exactly 12 pentagon cells per resolution at the icosahedron vertices, and they have five neighbours instead of six. They fall over open ocean and almost never hold land sensors, but if your neighbourhood-smoothing code assumes six neighbours it will misbehave there — guard with h3.h3_is_pentagon if you traverse adjacency.

Comparing counts across resolutions without normalizing. It is tempting to overlay a coarse and a fine density map and read them on the same colour scale, but a resolution-7 cell has roughly seven times the area of a resolution-8 cell, so equal counts mean very different densities. If you present multiple zoom levels, divide the count by cell area (h3.hex_area(resolution, unit="km^2")) to get a true density, or keep each level on its own scale and label it clearly.

Passing cell ids between library major versions. The h3 3.x API used here returns string cell ids from geo_to_h3; the 4.x line renames functions (latlng_to_cell) and changes some return types. Cell values are stable across versions for the same coordinate and resolution, but code is not — pin h3==3.7.6 across every producer and consumer so a mixed-version deployment does not fail on a renamed call. The centroid round-trip test above is your canary for a value-level mismatch.