All sections

H3 To Platform Native Geography

Mapping H3 cells to the opaque geo IDs a platform actually accepts, and recording the confidence and gaps that mapping introduces.

platform-dependentstableh36 min read
Source geometry
h3_cell_set
Destination geometry
platform_geo_id

Purpose

Many execution platforms — DSPs, walled-garden ad products, DOOH networks, loyalty systems — do not accept geometry at all. They accept a platform_geo_id: a proprietary integer, hash, or code that references a boundary the platform holds internally and does not expose. This page covers the adapter pipeline that turns a canonical H3 cell set into the best available set of those IDs, and the bookkeeping required because that translation is lossy and platform specific by construction.

Source geometry and destination geometry

Source geometry is an h3_cell_set — a set of H3 cells at a stated resolution, normalized to EPSG:4326. Destination geometry is platform_geo_id: not a geometry at all, but an identifier drawn from a platform's own geography namespace (a DMA-like market code, a proprietary "zone" id, a hashed geofence id). The distinction matters enough to restate plainly: an identifier is not a geometry. Two platforms can both expose an id labeled "zone_4471" and mean entirely different polygons; an id carries no shape, area, or boundary information on its own, only a lookup key into a boundary set the platform controls and can change without notice.

Exactness class

This conversion is approximate and platform dependent: the achievable accuracy is capped by whatever boundary set the platform publishes or licenses, which is frequently coarser than the H3 resolution being converted from, and is never guaranteed to be a clean partition of the platform's own serving area. A platform's boundaries can also be entirely undocumented, in which case the mapping is built empirically (see platform-native-ids-only below) and carries lower confidence than a mapping built from a published boundary file.

Adapter pipeline

flowchart LR
  H["H3 cell set<br/>(source resolution)"] --> U["Normalized polygon union<br/>(dissolve, repair, EPSG:4326)"]
  U --> X["Versioned platform<br/>geography crosswalk"]
  X --> P["Platform-native IDs<br/>(+ confidence, unmatched cells)"]

The H3 cells are first dissolved into a single normalized polygon union — not kept as discrete cells — because most platform boundary sets are themselves polygons, and polygon-to-polygon overlap is the only reliable basis for matching against an opaque, externally defined geography. That union is then run against a versioned crosswalk built specifically for that platform's boundary vintage, which resolves to zero, one, or several platform-native IDs depending on how the union overlaps the platform's own zones.

Containment rule and boundary behavior

A platform id is emitted for a cell (or, after dissolving, for a portion of the union) when the union's overlap with that platform zone exceeds a stated match_threshold — commonly a coverage-ratio cutoff such as 0.5, meaning the platform zone must account for at least half of the area under consideration before its id is included. Coverage below the threshold is recorded as a partial match, not silently dropped: it is retained with its actual overlap fraction so a caller can decide whether to include it. A cell (or union fragment) with no platform zone above any threshold is an unmatched cell, and must appear in the output as unmatched rather than be omitted, since omission is indistinguishable from "matched with zero weight" to a downstream reader.

Resolution behavior

Higher H3 resolution improves the fidelity of the union that gets matched against platform zones, but does not improve the platform side of the match — the platform's own boundary vintage is the binding constraint on achievable accuracy. Increasing source resolution beyond the point where the union already tracks the intended area tightly yields no further improvement in match quality, only more cells to dissolve.

Units and CRS

Both the H3-derived union and the platform boundary set are normalized to EPSG:4326 before intersection; match thresholds are computed on spherical (haversine-consistent) m² area, consistent with the area conventions used elsewhere in this knowledge base.

Algorithm

import { normalizePolygon } from "@/lib/geometry/normalize";
import { weightedCrosswalk } from "@/lib/h3/crosswalk";

// 1. Dissolve the H3 cell set into a single normalized polygon union.
const union = normalizePolygon(dissolveCellsToPolygon(cells));

// 2. Match against a versioned platform geography crosswalk.
const matches = weightedCrosswalk([union], platformZonePolygons, {
  weightBy: "area",
});

// 3. Keep matches above threshold; carry the rest as partial/unmatched.
const platformIds = matches.filter((m) => m.cell_coverage_fraction >= 0.5);
const partialMatches = matches.filter(
  (m) => m.cell_coverage_fraction > 0 && m.cell_coverage_fraction < 0.5
);

The same conversion with the Python bindings (h3-py v4):

import h3
from shapely.geometry import Polygon, MultiPolygon
from shapely.ops import unary_union

def cells_to_union(cells) -> Polygon | MultiPolygon:
    # h3-py's own dissolve: a cell set -> one (multi)polygon boundary.
    # cells_to_h3shape returns a LatLngMultiPoly; walk its polygons/rings
    # and flip each (lat, lng) vertex to shapely's (x, y) = (lng, lat).
    # (Attribute names below are illustrative — consult the h3-py shape
    # API for the exact accessor on the returned LatLngMultiPoly.)
    shape = h3.cells_to_h3shape(cells, tight=True)
    rings = [
        Polygon(
            [(lng, lat) for lat, lng in poly.outer],
            [[(lng, lat) for lat, lng in hole] for hole in poly.holes],
        )
        for poly in shape.polygons
    ]
    return unary_union(rings)

def crosswalk_to_platform(
    union: Polygon | MultiPolygon,
    platform_zones: dict[str, Polygon],
    match_threshold: float = 0.5,
    boundary_vintage: str = "unknown",
):
    # Pseudo-crosswalk: match the dissolved union against a platform's own
    # (externally sourced) zone polygons and bucket by coverage fraction.
    matched, partial = [], []
    covered = None
    for platform_id, zone_poly in platform_zones.items():
        overlap = union.intersection(zone_poly)
        if overlap.is_empty:
            continue
        coverage_fraction = overlap.area / union.area
        record = {
            "platform_geo_id": platform_id,
            "match_confidence": coverage_fraction,
            "boundary_vintage": boundary_vintage,
        }
        covered = overlap if covered is None else unary_union([covered, overlap])
        (matched if coverage_fraction >= match_threshold else partial).append(record)

    unmatched_area = union.area - (covered.area if covered is not None else 0.0)
    return {
        "matched": matched,
        "partial_matches": partial,
        "unmatched_fraction": unmatched_area / union.area,
    }

As with the crosswalk page above, shapely areas here are planar and only illustrative; the tested reference implementation in this knowledge base is the TypeScript in lib/, which computes match thresholds on spherical (haversine-consistent) area and records boundary_vintage and unmatched_cells explicitly rather than deriving them ad hoc.

Parameters

Match threshold, the platform's boundary vintage and ID namespace, and whether unmatched or partial-match cells should be surfaced to the caller or suppressed at delivery time (they should never be suppressed silently at the crosswalk-building step).

Outputs

A record per H3 union fragment: the resolved platform_geo_id (or explicit unmatched), match_confidence (derived from cell_coverage_fraction), the platform's id_namespace, the boundary_vintage of the platform file used, and a list of unmatched_cells and partial_matches that did not clear the threshold — this list is the primary artifact a media planner needs before claiming full coverage.

Quality metrics

coverage_ratio and jaccard, computed between the source H3 union and the union of all matched platform zones, quantify how much of the requested geography the platform can actually express. A low coverage_ratio with a long unmatched_cells list is a signal to renegotiate the requested geometry or accept a documented shortfall, not a bug in the crosswalk itself.

Edge cases

Some platforms expose platform-native-ids-only — no boundary file at all, only a picklist of ids with human-readable labels ("Downtown", "Zone 7"). In that case the crosswalk must be built empirically, by observing where the platform actually delivers against a known test geometry, and every such mapping should be flagged with materially lower confidence than one built from a licensed boundary file. Stale boundaries are a recurring failure mode here as well: platforms revise their internal zones without a corresponding version bump on their public documentation, so a crosswalk's boundary_vintage should be re-validated on a fixed schedule, not assumed durable once built.

Assumptions and limitations

This conversion assumes the platform's boundary set is knowable at all (published, licensed, or empirically reconstructable) and that its zones are static for the duration the crosswalk is in use — an assumption platforms do not always honor. When neither holds, the honest output is a documented unmatched result rather than a best-effort id chosen without a stated confidence.

Edge cases affecting this page
  • - No polygons/coordinates — everything must be crosswalked to platform IDs, losing sub-unit precision.
  • - Admin/postal/DMA boundaries change; using an old vintage misassigns cells.