Fixing an H3 Map That Misleads: Unequal Cell Areas and Empty Cells

Problem statement

A hexagon map of counts looks like the most honest map there is: same-shaped cells, a number in each, a colour ramp. It is easy to publish one that says something the data does not.

Two things go wrong, and neither raises an error.

The cells are not the same size. At resolution 5 the 12 pentagons cover 127.8 kmยฒ each and the largest hexagon covers 305.1 kmยฒ, a ratio of 2.39. A count is partly a measure of area. Measured on 13.46 million GeoNames points, dividing each count by its cell's area changed the class of 30.1% of cells on a 7-class quantile map, and the top 100 cells by count and by density shared only 72.

The empty cells are missing. Aggregating points with a group-by returns only cells that contain something. Over Chad at resolution 6, 65.6% of the land cells were empty, so the mean over the cells in the frame was 2.06 points while the mean over the country was 0.71 โ€” nearly three times lower. And a land-only cover of Norway dropped 111,836 of its 608,564 points, 18%, because coastal and sea features fell in cells whose centres were offshore.

Quick answer

Make both corrections before choosing colours:

import h3

counts = points.groupby("h3").size()                      # occupied cells only

cover = h3.h3shape_to_cells_experimental(
    h3.geo_to_h3shape(country), res, contain="overlap")    # every cell touching the region
full = counts.reindex(cover, fill_value=0)                 # empty cells become zeros

area = [h3.cell_area(c, "km^2") for c in full.index]
density = full / area                                       # map this, not the count

Then decide what the map is for. A density map answers "where is it concentrated?" A count map answers "where is the most of it?" and should say so in its legend.

Bar chart of H3 resolution-5 cell areas: pentagons, smallest hexagon, 5th percentile, median and largest cell.
Hexagons within one resolution differ by up to 1.98ร—, pentagons by 2.39ร—, wherever they are on Earth.

Step-by-step solution

1. Measure the area spread at your resolution

Every cell's area is available from h3.cell_area. Measured across all 2,016,842 cells at resolution 5:

mean 252.9 kmยฒ   min 127.8 (pentagons)   smallest hexagon 153.8   max 305.1
5th percentile 187.8   median 257.2   95th percentile 299.7

The middle 90% of cells span a factor of 1.6; the full hexagon range is 1.98. Coarse resolutions are more uniform: in random samples of hexagons the largest-to-smallest ratio was 1.21 at resolution 0, 1.94 at resolution 3 and 1.99 at resolution 9.

2. Do not expect the pattern to follow latitude

Geohash and web map tiles shrink steadily towards the poles, so their distortion is easy to picture. H3's is not like that. The correlation between cell area and absolute latitude was 0.016, and the full 127.8โ€“305.1 kmยฒ range appeared in six of the nine 10ยฐ latitude bands.

Cell size depends on position relative to the icosahedron's vertices, where the pentagons sit. The smallest hexagon at resolution 5 was at 64.7ยฐ N, 10.3ยฐ E, right next to the pentagon at 64.7ยฐ N, 10.5ยฐ E. A map of counts therefore has small, patchy area distortions scattered across the world, not a gradient anyone would notice.

3. Divide by area before comparing cells

The correction is one line and changes more than the correlation suggests:

all 451,015 occupied resolution-5 cells
Spearman rank correlation, count vs density      0.9951
top 1,000 cells in both rankings                  739
top 100 cells in both rankings                    72
cells changing quintile class                     10.7%
cells changing 7-class quantile                   30.1%

A rank correlation of 0.995 sounds like nothing changed. But classed maps and top-N lists are decided at boundaries, and area differences of up to 2ร— move cells across them. The busiest resolution-5 cell held 6,716 points in 172.5 kmยฒ; the next held 4,718 in 245.8 kmยฒ, so its density was half the first's rather than 70%.

4. Add the empty cells back

A group-by knows nothing about cells where nothing happened. Build the full cover of the region and reindex with zeros:

country     res   cells in cover   empty    mean, occupied   mean, all   median, occupied   median, all
Chad         6        32,059       65.6%         2.06           0.71             1                 0
UK           7        53,100       26.8%         2.57           1.88             2                 1
Norway       6        14,447       13.1%        39.57          34.38            28                20

Every summary statistic, every classification scheme computed from the data and every legend changes. A quantile map of Chad without the empty cells has no class for "nothing here" โ€” two thirds of the country.

5. Cover coastal regions with overlap, not centres

The default fill keeps a cell only if its centre is inside the polygon, so coastal cells with offshore centres are left out, along with every point in them:

country     points outside the cover (centre)   outside the cover (overlap)
Norway              111,836                               28,968
UK                    9,230                                3,306
Chad                    144                                   23

Norway's losses were mostly sea and landform features: 64,311 hypsographic and 27,745 hydrographic records, and 92,241 of the lost points were within one ring of a covered cell. An overlap cover keeps most of them. The 28,968 it still missed lie in cells that do not touch the Natural Earth polygon at all โ€” features further offshore, and territories the polygon leaves out, such as the 1,152 points north of 74ยฐ.

6. Flag pentagons if the map is global

The 12 pentagons are 50% smaller than the median cell at resolution 5 and have five neighbours instead of six. At resolution 5 none of them is on land, and in the GeoNames aggregation one occupied cell was a pentagon. A global ocean dataset will meet all twelve. h3.is_pentagon identifies them.

7. Label the map for what it shows

A density map normalised by cell_area and a count map are different claims. Put the unit in the legend โ€” points per kmยฒ, or points per cell โ€” and state the resolution. Readers compare hexagons as if they were equal; the legend is the only place to tell them they are not.

Table of empty H3 cell shares and mean points per cell over occupied and over all cells for Chad, the United Kingdom and Norway.
Group-by output contains only cells that had something in them. The empty ones have to be added back.

Code examples

Example 1 โ€” counts with area, density and pentagon flags

import h3


def add_area_and_density(counts, unit="km^2"):
    """counts: a pandas Series of point counts indexed by H3 cell."""
    frame = counts.rename("n").to_frame()
    res = h3.get_resolution(frame.index[0])
    frame["area"] = [h3.cell_area(c, unit) for c in frame.index]
    frame["density"] = frame["n"] / frame["area"]
    frame["area_vs_average"] = frame["area"] / h3.average_hexagon_area(res, unit)
    frame["pentagon"] = [h3.is_pentagon(c) for c in frame.index]
    spread = frame["area"].max() / frame["area"].min()
    print(f"{len(frame):,} cells at res {res}: area {frame['area'].min():.1f}-{frame['area'].max():.1f} {unit} "
          f"(max/min {spread:.2f}), pentagons {int(frame['pentagon'].sum())}")
    return frame
451,015 cells at res 5: area 127.8-305.1 km^2 (max/min 2.39), pentagons 1
15,805 cells at res 6: area 18.2-31.0 km^2 (max/min 1.70), pentagons 1

The second line is Norway alone. A single country still spans a factor of 1.7, so the correction matters locally as well as globally. area_vs_average is useful for a quick look at which cells a count map flatters.

Example 2 โ€” a complete cover with zeros

import h3


def complete_cells(region, counts, res, contain="overlap"):
    """Every cell covering the region, with zeros where nothing was counted."""
    shape = h3.geo_to_h3shape(region)
    cover = h3.h3shape_to_cells_experimental(shape, res, contain=contain)
    full = counts.reindex(cover, fill_value=0).rename("n")
    outside = counts[~counts.index.isin(cover)].sum()
    empty = float((full == 0).mean())
    print(f"{len(cover):,} cells ({contain}); {100 * empty:.1f}% empty; "
          f"mean {full.mean():.2f} over all cells vs {counts[counts.index.isin(cover)].mean():.2f} over occupied; "
          f"{int(outside):,} points fell outside the cover")
    return full
Norway
14,447 cells (center); 13.1% empty; mean 34.38 over all cells vs 39.57 over occupied; 111,836 points fell outside the cover
17,118 cells (overlap); 15.1% empty; mean 34.70 over all cells vs 40.53 over occupied; 28,968 points fell outside the cover

The outside figure is the check that matters. A cover that silently discards points makes every downstream statistic describe a smaller dataset than the one you loaded.

Example 3 โ€” how much the story changes

from scipy.stats import spearmanr


def count_versus_density(frame, top=1000, shift=0.10):
    """How much the map's story changes when counts are divided by cell area."""
    rho = spearmanr(frame["n"], frame["density"]).correlation
    top_count = set(frame.nlargest(top, "n").index)
    top_density = set(frame.nlargest(top, "density").index)
    pct_count = frame["n"].rank(pct=True)
    pct_density = frame["density"].rank(pct=True)
    moved = (pct_count - pct_density).abs() > shift
    print(f"Spearman {rho:.4f}; top {top} by count and by density share {len(top_count & top_density)}; "
          f"{100 * moved.mean():.1f}% of cells move more than {int(100 * shift)} percentile points")
    return frame.assign(pct_count=pct_count, pct_density=pct_density)
Spearman 0.9951; top 1000 by count and by density share 739; 0.1% of cells move more than 10 percentile points
Spearman 0.9951; top 100 by count and by density share 72; 8.6% of cells move more than 5 percentile points
Spearman 0.9983; top 100 by count and by density share 95; 0.0% of cells move more than 10 percentile points

The first two lines are the global aggregation; the third is Norway at resolution 6. Few cells move far, but many move a little, and a little is enough to cross a class break or leave a top-100 list.

Explanation

Why H3 cells differ in area at all

H3 starts from an icosahedron, projects a hexagonal grid onto each face with a gnomonic projection, and needs 12 pentagons at the vertices to close the surface. The projection stretches cells more the further they are from a face centre, and the pentagons pinch the grid around them. The result is a set of cells that are equal in topology but not in area.

That is also why the variation is independent of latitude: the icosahedron's vertices and faces are placed without regard to the equator, and one pentagon at resolution 5 sits at 64.7ยฐ N while another sits at 2.3ยฐ N.

Why a high rank correlation still changes the map

Spearman's correlation measures whether the two orderings agree overall. Maps and rankings depend on local order at a few thresholds. With 451,015 cells, a cell only needs to move a few percentile points to cross from one class of a 7-class map into the next, and 8.6% of cells moved more than five. The two measures agree about the world and disagree about the map.

Why missing zeros bias everything upwards

Summarising only occupied cells conditions every statistic on "something was here". The mean, the median, the class breaks and the colour of the lowest class all come from a dataset with no zeros in it. In sparse regions that condition removes most of the country: two thirds of Chad's cells. The map then shows variation among the few occupied cells as if it described the whole area.

Why centre containment loses coastal data

A region's cover built with centre containment excludes every cell whose centre is outside the polygon, even if most of the cell is on land and most of its points are on the coast. Norway's coastline is long and fractal at a 30 kmยฒ cell size, and GeoNames records bays, skerries and headlands along all of it. Overlap containment includes those cells and kept 82,868 more of Norway's points.

Checklist for an H3 count map: divide by cell area, add empty cells, use an overlap cover, flag pentagons, and do not assume area follows latitude.
Each check changed a measured result; none of them raises an error if skipped.

Edge cases or notes

  • Overlap covers extend beyond the region. Cells that are mostly sea get low densities; clip the display, not the counts, if that looks wrong.
  • Fine resolutions make most cells empty or single-point. At resolution 7 the median occupied GeoNames cell holds one point; aggregate coarser before normalising.
  • cell_area is exact per cell; average_hexagon_area(res) is only the resolution's mean and should not be used as the divisor.
  • Pentagons have five neighbours. k-ring smoothing around them averages fewer cells; flag them in any neighbourhood statistic.
  • Area-weighting is a density, not a rate. For people or events per population, divide by the population in the cell, not its area.
  • Colour classes should come from the complete frame, zeros included, or the lowest class describes the emptiest occupied cells rather than empty land.
  • Compacted cell sets mix resolutions. Uncompact before mapping counts from them.

FAQ

Are all H3 hexagons the same size?

No. At resolution 5 hexagons range from 153.8 to 305.1 kmยฒ, and the 12 pentagons are 127.8 kmยฒ. The middle 90% of cells span a factor of about 1.6.

Are H3 cells smaller near the poles?

Not systematically. The correlation between cell area and latitude was 0.016; size depends on position relative to the icosahedron's vertices, where the pentagons are.

Should I map counts or densities in hexagons?

Densities, unless the map is explicitly about totals. Dividing by cell_area changed the class of 30.1% of cells on a 7-class quantile map and replaced 28 of the top 100 cells.

Why is the average count per hexagon too high?

A group-by only returns occupied cells, so empty cells are missing from the average. Over Chad the mean was 2.06 over occupied cells and 0.71 over all cells covering the country.

Why are points missing when I restrict the map to a country?

The country's cover probably uses centre containment, which drops coastal cells whose centres are offshore. Norway lost 111,836 points that way; an overlap cover lost 28,968.

Do pentagons matter for a regional map?

Rarely. At resolution 5 none of the 12 pentagons is on land. Global and ocean datasets meet them, and they are about half the area of a median cell.