Aggregating Sensor Data with H3 Cells
This page aggregates a digital twin’s sensor readings into H3 hexagonal cells — choosing a resolution from the sensor spacing rather than by habit, binning and aggregating per cell and time bucket, coarsening through the hierarchy for zoomed-out views, and rendering the result as an overlay on the tiled city, with source coordinates in EPSG:25832 converted to WGS84 for indexing.
Why you hit this
A twin with ten thousand sensors — traffic counters, air quality, noise, occupancy, temperature — cannot show them as ten thousand markers, and their raw positions are not what anybody asks about. The questions are aggregate and spatial: which streets are loudest between seven and nine, where does particulate matter exceed a threshold, how does occupancy vary by district. Binning into a fixed cell system answers all of them with one aggregation, and H3’s hexagons have the property that matters for this: every neighbour is the same distance away, so a diffusion, a gradient or a neighbourhood average behaves consistently in all directions. The alternatives are compared in choosing between S2, H3 and geohash for 3D data.
Prerequisites
- Python 3.10+ with
h3>=4.0,pandas>=2.0,geopandas>=0.14,pyproj>=3.6,shapely>=2.0. - Sensor readings with a position, a timestamp and a value. Positions in a projected CRS need converting to WGS84, because H3 indexes latitude and longitude.
- A sense of the sensor spacing and of the smallest area a reading should be attributed to — both feed the resolution choice.
Step-by-Step
1. Choose the resolution from the sensor spacing
import h3
def resolution_table(resolutions=range(7, 14)):
rows = []
for r in resolutions:
area_m2 = h3.average_hexagon_area(r, unit="m^2")
edge_m = h3.average_hexagon_edge_length(r, unit="m")
rows.append({"resolution": r,
"avg_area_m2": round(area_m2, 1),
"avg_edge_m": round(edge_m, 1),
"cells_per_km2": round(1e6 / area_m2, 1)})
return rows
for row in resolution_table():
print(f"res {row['resolution']:>2} area {row['avg_area_m2']:>12,.1f} m² "
f"edge {row['avg_edge_m']:>8.1f} m {row['cells_per_km2']:>8.1f} cells/km²")
The rule that works is to pick the resolution whose edge length is close to the sensor spacing. Cells much smaller than the spacing are mostly empty, so the map becomes a scatter of isolated hexagons; cells much larger average away the variation the sensors were installed to measure. For traffic counters every 200–400 m along streets, resolution 9 or 10 is right; for air quality stations several kilometres apart, resolution 7; for indoor occupancy sensors, resolution 13 or finer.
2. Index the readings
import pandas as pd
from pyproj import Transformer
to_wgs84 = Transformer.from_crs(25832, 4326, always_xy=True)
RES = 10
def index_readings(df, res=RES):
lon, lat = to_wgs84.transform(df["easting"].to_numpy(), df["northing"].to_numpy())
df = df.assign(lon=lon, lat=lat)
df["cell"] = [h3.latlng_to_cell(la, lo, res) for la, lo in zip(lat, lon)]
return df
readings = pd.read_parquet("data/noise_readings_2026-09.parquet")
readings = index_readings(readings)
print(f"{len(readings):,} readings, {readings['cell'].nunique():,} distinct cells at res {RES}")
print(readings.head(3)[["sensor_id", "timestamp", "value_db", "cell"]])
H3 takes latitude first and longitude second, which is the opposite order from most projected-coordinate code and the source of a good share of H3 bugs: swap them and every reading lands in the Indian Ocean or the Sahara, both of which are conveniently far from any plausible city and therefore obvious. Less obvious is a partial swap in one code path, which puts a subset of readings somewhere implausible while the rest are fine.
3. Aggregate per cell and time bucket
def aggregate(df, freq="1h", value="value_db"):
df = df.copy()
df["bucket"] = pd.to_datetime(df["timestamp"]).dt.floor(freq)
grouped = df.groupby(["cell", "bucket"]).agg(
n=(value, "size"),
mean=(value, "mean"),
p95=(value, lambda s: s.quantile(0.95)),
max=(value, "max"),
sensors=("sensor_id", "nunique"),
).reset_index()
grouped["mean"] = grouped["mean"].round(2)
grouped["p95"] = grouped["p95"].round(2)
return grouped
agg = aggregate(readings)
print(agg.sort_values("p95", ascending=False).head(5))
print(f"{len(agg):,} cell-hour rows")
Keeping the sensor count per cell alongside the statistics is what makes the result honest. A cell with one sensor and a mean of 71 dB reports that sensor; a cell with six sensors and the same mean reports a neighbourhood. Rendering both identically invites a reader to over-interpret the first, and a map that greys out single-sensor cells at coarse resolutions is usually the right presentation.
4. Coarsen carefully: the hierarchy does not nest exactly
def coarsen(agg, from_res=RES, to_res=8):
a = agg.copy()
a["parent"] = [h3.cell_to_parent(c, to_res) for c in a["cell"]]
out = a.groupby(["parent", "bucket"]).agg(
n=("n", "sum"),
mean=("mean", "mean"), # unweighted: see the caveat below
weighted_mean=("mean", lambda s: None),
p95=("p95", "max"),
child_cells=("cell", "nunique"),
).reset_index()
# weight the mean by reading count, which the naive mean of means does not
wm = (a.assign(prod=a["mean"] * a["n"]).groupby(["parent", "bucket"])
.agg(num=("prod", "sum"), den=("n", "sum")).reset_index())
out = out.merge(wm, on=["parent", "bucket"])
out["weighted_mean"] = (out["num"] / out["den"]).round(2)
return out.drop(columns=["num", "den"])
coarse = coarsen(agg)
print(coarse.sort_values("n", ascending=False).head(4))
Two traps live in that function. The first is the mean of means, which is wrong whenever cells have different reading counts — the weighted version is one extra group-by and is the number to publish. The second is H3’s hierarchy itself: hexagons cannot tile a hexagon, so a resolution-9 cell is not exactly the union of seven resolution-10 cells. cell_to_parent gives the cell whose centre contains the child’s centre, and a child straddling a parent boundary is assigned wholly to one parent. For aggregation of point readings that is harmless; for aggregating areas — a land-cover share, a footprint total — it introduces an error of a few percent per level, and the right approach there is to re-aggregate from the source rather than to roll up.
5. Turn cells into geometry for the overlay
import geopandas as gpd
from shapely.geometry import Polygon
def cells_to_gdf(rows, cell_col="cell", crs=4326):
geoms, records = [], []
for row in rows.itertuples(index=False):
cell = getattr(row, cell_col)
boundary = h3.cell_to_boundary(cell) # [(lat, lon), …]
geoms.append(Polygon([(lon, lat) for lat, lon in boundary]))
records.append(row._asdict())
gdf = gpd.GeoDataFrame(records, geometry=geoms, crs=crs)
gdf["area_m2"] = gdf.to_crs(25832).geometry.area.round(1)
return gdf
peak = agg[agg["bucket"] == agg["bucket"].max()]
hexes = cells_to_gdf(peak)
hexes.to_file("web/noise_hex_res10.geojson", driver="GeoJSON")
print(hexes[["cell", "n", "mean", "p95", "area_m2"]].head(4))
print(f"area range {hexes['area_m2'].min():,.0f}–{hexes['area_m2'].max():,.0f} m²")
cell_to_boundary returns latitude-longitude pairs, so building a Shapely polygon needs them reversed — the same order trap as indexing, in the opposite direction. The area range printed at the end is worth looking at once: H3 cells vary in area with latitude and position within the icosahedral face, typically by a few percent within a city and much more across a country. Any per-area quantity — a density, a rate per hectare — has to divide by each cell’s own area rather than by the resolution’s average.
6. Render it over the tiled city
const hexes = await Cesium.GeoJsonDataSource.load("/web/noise_hex_res10.geojson", {
clampToGround: false,
});
viewer.dataSources.add(hexes);
const ramp = [[55, "#1f6b8a"], [60, "#4f7a4d"], [65, "#c46a3d"], [70, "#b0413e"]];
for (const entity of hexes.entities.values) {
const p95 = entity.properties.p95.getValue();
const stop = ramp.findLast(([threshold]) => p95 >= threshold) ?? ramp[0];
entity.polygon.material = Cesium.Color.fromCssColorString(stop[1]).withAlpha(0.55);
entity.polygon.extrudedHeight = 4 + (p95 - 50) * 2.5; // exaggerate for readability
entity.polygon.height = 0;
entity.polygon.outline = false;
}
Extruding the hexagons by the value turns the overlay into a readable surface over the city rather than a flat choropleth competing with the buildings for attention. Stating the exaggeration in the legend matters, because a 60 m tall hexagon representing 68 dB will otherwise be read as a building.
For a layer that changes continuously — live sensor feeds rather than an hourly aggregate — the geometry should be built once and only the values updated, as in streaming live sensor updates onto tilesets.
Expected Output & Verification
res 7 area 5,161,293.4 m² edge 1406.5 m 0.2 cells/km²
res 8 area 737,327.6 m² edge 531.4 m 1.4 cells/km²
res 9 area 105,332.5 m² edge 200.8 m 9.5 cells/km²
res 10 area 15,047.5 m² edge 75.9 m 66.5 cells/km²
res 11 area 2,149.6 m² edge 28.7 m 465.2 cells/km²
res 12 area 307.1 m² edge 10.8 m 3,256.2 cells/km²
res 13 area 43.9 m² edge 4.1 m 22,793.6 cells/km²
1,204,882 readings, 3,118 distinct cells at res 10
cell bucket n mean p95 max sensors
0 8a1fb46622dffff 2026-09-15 08:00 84 71.42 78.10 81.2 3
3,118 distinct cells at res 10
104,204 cell-hour rows
area range 14,802–15,244 m²
Verify the indexing before trusting any aggregate, because a coordinate-order error produces a perfectly consistent map of the wrong place:
def verify_indexing(df, res=RES, sample=2000, tolerance_m=200.0):
from pyproj import Geod
geod = Geod(ellps="WGS84")
s = df.sample(min(sample, len(df)), random_state=7)
dists = []
for row in s.itertuples(index=False):
lat_c, lon_c = h3.cell_to_latlng(row.cell)
_, _, d = geod.inv(row.lon, row.lat, lon_c, lat_c)
dists.append(d)
dists = pd.Series(dists)
return {"checked": len(dists), "max_offset_m": round(float(dists.max()), 1),
"median_offset_m": round(float(dists.median()), 1),
"within_cell": bool(dists.max() < tolerance_m)}
check = verify_indexing(readings)
print(check)
assert check["within_cell"], "readings are not inside their cells: latitude/longitude swapped?"
Every reading must lie within its own cell, so the distance from the reading to its cell centre cannot exceed the cell’s circumradius — about 76 m at resolution 10. A median offset of thousands of kilometres is the swapped-order bug; an offset of a few hundred metres on some readings means those were indexed at a different resolution.
Then verify the aggregation preserves the data:
assert int(agg["n"].sum()) == len(readings), "readings lost in aggregation"
recomputed = (readings.assign(bucket=pd.to_datetime(readings["timestamp"]).dt.floor("1h"))
.groupby(["cell", "bucket"])["value_db"].mean().round(2))
merged = agg.set_index(["cell", "bucket"])["mean"]
assert merged.sub(recomputed).abs().max() < 0.011, "aggregated means do not reproduce"
print("aggregation reproduces the source readings")
Performance Notes
- Indexing is about a microsecond per point in the C-backed library, so a million readings index in a second or two. Vectorise by list comprehension rather than
apply, which is several times slower. - Aggregate in the database when the data lives there. DuckDB and PostGIS both have H3 extensions, and grouping ten million rows in SQL beats moving them into pandas.
- Store the cell as a string or a 64-bit integer, not both. The integer form is half the size and sorts usefully; the string form is what most APIs expect. Pick one for storage and convert at the edges.
- Pre-compute the boundary geometry per cell once and cache it: it is fixed for a given cell, and re-deriving it per render is wasted work.
- Coarsen from the source for area quantities, from the aggregate for counts and means. The first is correct and slower; the second is fast and approximate.
Common Errors
Every reading lands in the ocean. Latitude and longitude were passed in the wrong order. H3 takes latitude first.
Cell areas differ from the resolution’s average. Expected: H3 cells vary in area. Divide by each cell’s own area for any per-area statistic.
A coarse view shows higher values than any fine cell. The coarse layer used max of the children’s p95, which is a legitimate choice and not the same statistic as the parent’s own p95. Name the statistic in the legend.
Twelve pentagons behave oddly. H3 has twelve pentagonal cells per resolution, inherited from the icosahedron, and they have five neighbours instead of six. Any neighbourhood computation has to tolerate that; grid_disk does it correctly, and hand-rolled neighbour arithmetic does not.
Counts do not add up after a roll-up. Cells were rolled up twice, or a cell straddling a parent boundary was double-counted by a manual containment test. Use cell_to_parent, which assigns each child to exactly one parent.
Frequently Asked Questions
H3 or a square grid?
H3 for anything involving neighbourhoods, flow or diffusion, because all six neighbours are equidistant. A square grid — or the quadkey scheme the tiling already uses — for anything that has to align with tiles, imagery or existing raster products. Mixing them in one twin is normal: hexagons for analysis, quadkeys for delivery.
Can H3 index three dimensions?
No. It is a surface tessellation, so a sensor at street level and one on a roof in the same hexagon share a cell. Where the vertical matters, carry the height as an attribute and bin it separately, or use a 3D scheme as discussed in octree indexing point clouds with Morton codes.
How do I join hexagon aggregates to buildings?
By spatial join, not by cell arithmetic: a building can overlap several cells, and attributing it to the cell containing its centroid is a choice worth stating. For per-building values, aggregate to buildings directly and use hexagons only for the area view.
Related Guides
- Choosing Between S2, H3 and Geohash for 3D Data — why hexagons, and when not
- Serving Tile Lookups from PostGIS — doing the aggregation in the database
- Rendering Thousands of Labels and Billboards — the alternative when individual sensors must be shown