Purpose
A mixed-resolution cell set is one that contains H3 cells from more than one resolution at once — a resolution-6 cell alongside a resolution-9 cell in the same array. This is a normal, expected shape for data that has been through compaction, hierarchical rollups, or merged from two sources built at different working resolutions. It is not, however, a shape that any set operation — union, intersection, difference, equality check, or membership test — can be run against correctly without normalization first. This page defines why, and states the exact normalization rule.
Why mixed-resolution sets arise
The most common source is compaction: a uniform resolution-9 cell set gets compacted for storage or transmission, and any region where all seven children of a resolution-8 parent are present gets replaced by that one parent cell — the compacted set is now, by design, a mix of resolution 8 and resolution 9 cells (and potentially coarser, if compaction cascades upward). Mixed sets also arise from merging two independently built cell sets (an inclusion built at resolution 7, an exclusion built at resolution 9), or from a rollup that stores results at whatever resolution each region's data was originally collected at.
Why comparison requires normalization
H3 cells are opaque 64-bit indices; a resolution-8 cell and a resolution-9
cell that geometrically nest inside one another do not share any bit pattern
that a naive equality or set-membership check can exploit. Testing whether a
resolution-9 cell is "in" a set that actually contains its resolution-8
parent requires walking the hierarchy (cellToParent upward from the
resolution-9 cell, or cellToChildren downward from the resolution-8 cell)
— a plain Set.has() on raw cell indices will report a false negative every
time, because the parent index and the child index are different numbers.
The same problem applies to union, intersection, and difference: two mixed
sets cannot be unioned by simple array concatenation, because a parent cell
in one set and its own children in the other set are the same geography
expressed two different ways, and naively combining them produces silent
double-counting rather than a correct union.
normalizeToResolution behavior
normalizeToResolution resolves every cell in a mixed set to one target
resolution by expanding or folding as needed:
- Coarser cell than target
- Expanded to its children at the target resolution via cellToChildren. A single resolution-6 cell becomes 7 resolution-7 children, 49 resolution-8 grandchildren, and so on.
- Finer cell than target
- Folded up to its ancestor at the target resolution via cellToParent, then deduplicated against any other cell in the set that folds to the same ancestor.
- Cell already at target resolution
- Passed through unchanged.
The result is a uniform-resolution set that supports ordinary set semantics again: two normalized sets can be unioned, intersected, or diffed with plain set operations, and cell-count based area estimates become meaningful because every cell in the result has the same nominal area.
Parent-child duplicates and double counting
A parent-child duplicate is a mixed set that contains both a cell and one
or more of that cell's descendants (or ancestors) — for example, a
resolution-7 parent cell and one of its own resolution-9 grandchildren, both
present in the same array. Left unnormalized, any per-cell aggregation (a
population count per cell, a spend figure per cell) will count the
overlapping ground twice: once under the parent's row, once under the
child's row, because both rows describe overlapping geography under
different indices. hasParentChildDuplicate detects this condition before it
reaches an aggregation step, since the fix (normalize to one resolution
first) is cheap but only if applied before, not after, values have already
been summed.
Logical versus geometric containment
cellToChildren and cellToParent define a logical hierarchy — the H3
indexing scheme's own parent-child bookkeeping — and it is tempting to treat
that hierarchy as geometrically exact: to assume a child cell's boundary lies
entirely within its parent's boundary. It does not, in general. H3's grid is
built on an icosahedral projection, and near cell distortion (most visibly
around pentagons and face-crossing cells, but present at lower magnitude
throughout the grid) a child cell's true polygon boundary can extend slightly
outside its logical parent's polygon boundary. Do not use cellToParent /
cellToChildren as a substitute for an actual point-in-polygon or
polygon-intersection test when geometric exactness matters (for example,
verifying that a fine-resolution cell physically falls inside a specific
coarse-resolution polygon) — the hierarchy is exact as an indexing
relationship and only approximately exact as a geometric one.
Algorithm
import {
normalizeToResolution,
hasParentChildDuplicate,
} from "@/lib/h3/hierarchy";
// Detect before aggregating — cheap check, expensive mistake to skip.
if (hasParentChildDuplicate(mixedCells)) {
flagForNormalization(mixedCells);
}
// Fold coarse cells down / expand fine cells up to one target resolution.
const uniform = normalizeToResolution(mixedCells, { resolution: 9 });
The same conversion with the Python bindings (h3-py v4):
import h3
def normalize_to_resolution(cells, resolution: int) -> set[str]:
normalized = set()
for cell in cells:
res = h3.get_resolution(cell)
if res == resolution:
normalized.add(cell)
elif res < resolution:
normalized.update(h3.cell_to_children(cell, resolution))
else:
normalized.add(h3.cell_to_parent(cell, resolution))
return normalized
def has_parent_child_duplicate(cells) -> bool:
by_resolution: dict[int, set[str]] = {}
for cell in cells:
by_resolution.setdefault(h3.get_resolution(cell), set()).add(cell)
resolutions = sorted(by_resolution)
for i, coarse_res in enumerate(resolutions):
for fine_res in resolutions[i + 1:]:
for cell in by_resolution[fine_res]:
ancestor = h3.cell_to_parent(cell, coarse_res)
if ancestor in by_resolution[coarse_res]:
return True
return False
# Detect before aggregating — cheap check, expensive mistake to skip.
if has_parent_child_duplicate(mixed_cells):
flag_for_normalization(mixed_cells)
# Fold coarse cells down / expand fine cells up to one target resolution.
uniform = normalize_to_resolution(mixed_cells, resolution=9)
The tested reference implementation in this knowledge base is the
TypeScript in lib/; this Python walks each cell's resolution with
get_resolution and expands/folds it with cell_to_children /
cell_to_parent, exactly as normalizeToResolution does.
Edge cases
Mixed resolutions show up constantly at the boundary between an inclusion built at one resolution and an exclusion built at another — normalize both to the same resolution before ever computing a set difference between them. Parent-child duplicates are the concrete symptom to test for whenever two cell sets from different pipelines are merged; a duplicate check should run as a matter of course on any merged set before it is used for area estimation, reporting, or billing.
Assumptions and limitations
Normalization assumes every cell in the mixed set is a valid H3 index at a resolution the target conversion supports; it does not repair cells that were corrupted upstream (wrong resolution recorded in metadata, cell indices from a different H3 base cell numbering scheme). It also does not restore information lost by an earlier lossy conversion — normalizing to a uniform resolution recovers a comparable set, not the original source geometry that produced the mixed set in the first place.
