# Geographic Interoperability Knowledge Base > Open, versioned, tested documentation for converting geographic geometry to and from H3 and executable ad-platform geography: source -> normalized -> canonical H3 -> executed -> reported. Distances are spherical (haversine); areas in m2; CRS EPSG:4326. This file follows the llmstxt.org convention. For the whole corpus in one request, fetch https://etherdata.ai/geo-interop-kb/llms-full.txt (markdown) or https://etherdata.ai/geo-interop-kb/kb.json (structured). Each page below is also available as clean markdown at https://etherdata.ai/geo-interop-kb/docs/.md. ## geometries - [Administrative Boundaries](https://etherdata.ai/geo-interop-kb/docs/administrative-boundaries.md): The family of governmentally or institutionally defined partitions — country down to school district — and why the id you're handed is never the boundary itself. - [Arbitrary Polygons](https://etherdata.ai/geo-interop-kb/docs/arbitrary-polygons.md): Operator-drawn or model-generated shapes with no external authority governing their boundary — trade areas, geofences, parcels, and the risks that come from having no ground truth to check against. - [Bounding Boxes](https://etherdata.ai/geo-interop-kb/docs/bounding-boxes.md): A [west, south, east, north] envelope — a map viewport or query bound, not a shape describing any real-world extent — and the ordering and antimeridian bugs that follow from treating it as one anyway. - [Geometry Catalogue](https://etherdata.ai/geo-interop-kb/docs/geometry-catalogue.md): An index of every geometry type this knowledge base converts, grouped by category, with the metadata each needs and the single rule that an identifier is never itself a geometry. - [Lines And Trajectories](https://etherdata.ai/geo-interop-kb/docs/lines-and-trajectories.md): Unordered polylines versus ordered, timestamped position sequences — roads and rivers on one side, device journeys on the other, with very different exposure profiles. - [Multipoints](https://etherdata.ai/geo-interop-kb/docs/multipoints.md): A collection of independent point observations rather than one coherent shape — bid requests, visits, and conversions aggregated to cells, where duplication and sparsity are the dominant failure modes. - [Platform Identifiers](https://etherdata.ai/geo-interop-kb/docs/platform-identifiers.md): Opaque or standardized ids — FIPS, ISO, DMA, publisher market codes — that reference a geometry through a versioned crosswalk but are never a geometry themselves. The central case for this catalogue's one recurring warning. - [Point-Radius Geometries](https://etherdata.ai/geo-interop-kb/docs/point-radius-geometries.md): A center coordinate plus a radius — the native execution unit for most DSPs and proximity products, and the geometry where the buffer method matters as much as the containment rule after it. - [Points](https://etherdata.ai/geo-interop-kb/docs/points.md): A single coordinate that references a place rather than describing an extent — POIs, addresses, devices, and the uncertainty each one silently carries. - [Rasters](https://etherdata.ai/geo-interop-kb/docs/rasters.md): Gridded fields — population, elevation, weather, land use, imagery, audience-density surfaces — and the resolution-mismatch problems that surface the moment a fixed pixel grid meets a hexagonal cell grid. ## source-to-h3 - [Administrative Polygon To H3](https://etherdata.ai/geo-interop-kb/docs/administrative-polygon-to-h3.md): Converting counties, states, DMAs, census geographies, and postal areas into H3 cells while preserving the partition property those units are supposed to have. - [Arbitrary Polygon To H3](https://etherdata.ai/geo-interop-kb/docs/arbitrary-polygon-to-h3.md): The four containment modes for polyfilling any polygon into H3 cells, when to use each, and how to retain overlap for downstream weighting. - [Bounding Box To H3](https://etherdata.ai/geo-interop-kb/docs/bounding-box-to-h3.md): Normalizing a west/south/east/north bounding box into a valid polygon before polyfilling, and the ordering and wraparound bugs that skip this step invites. - [Line And Corridor To H3](https://etherdata.ai/geo-interop-kb/docs/line-and-corridor-to-h3.md): Four distinct ways to turn a road segment or device trajectory into H3 cells, and why GPS noise makes the choice consequential. - [Point To H3](https://etherdata.ai/geo-interop-kb/docs/point-to-h3.md): Point-to-cell containment is exact given a coordinate; the real subject of this page is how uncertain that coordinate usually is. - [Point-Radius To H3](https://etherdata.ai/geo-interop-kb/docs/point-radius-to-h3.md): Buffering a point into a geodesic disk before polyfilling, and why the buffer method matters as much as the containment rule that follows it. - [Raster To H3](https://etherdata.ai/geo-interop-kb/docs/raster-to-h3.md): Choosing the correct aggregation statistic when resampling a gridded raster into H3 cells, and why the wrong choice manufactures false precision. ## advertising - [Advertising Geographic Matching Semantics](https://etherdata.ai/geo-interop-kb/docs/advertising-geographic-matching-semantics.md): Geometry alone does not define who gets targeted; the matching semantic, location source, and lookback window determine the audience as much as the shape does. ## systems - [Antimeridian Handling](https://etherdata.ai/geo-interop-kb/docs/antimeridian-handling.md): Geometries and circles that cross the ±180° meridian wrap incorrectly under planar longitude math and must be split or unwrapped before any H3 or area operation. - [Cell System Comparison](https://etherdata.ai/geo-interop-kb/docs/cell-system-comparison.md): A matrix comparison of H3, S2, and Geohash across shape, hierarchy, equal-area, and containment behavior, and why cross-system conversion always goes through polygon union and re-fill. - [Coordinate And CRS Failures](https://etherdata.ai/geo-interop-kb/docs/coordinate-and-crs-failures.md): Nine recurring data-quality failures in supplied coordinates and boundaries, each with a concrete detection test and mitigation, that must be cleared before any geometry enters the conversion pipeline. - [Geohash Overview](https://etherdata.ai/geo-interop-kb/docs/geohash-overview.md): Geohash as a system: base-32 prefix strings over a recursively bisected lat/lng rectangle, exact prefix containment, 1-12 character lengths, non-equal-area cells, and no native polygon fill. - [H3 Overview](https://etherdata.ai/geo-interop-kb/docs/h3-overview.md): H3 as a system: icosahedron projection, aperture-7 hierarchy, 16 resolutions, hexagon-dominant cells with 12 unavoidable pentagons, and logical (not exact) parent-child containment. - [H3 Pentagons](https://etherdata.ai/geo-interop-kb/docs/h3-pentagons.md): Twelve pentagon cells per H3 resolution sit at the icosahedron vertices and break the six-neighbour, regular-shape assumptions that most H3 code implicitly relies on. - [S2 Overview](https://etherdata.ai/geo-interop-kb/docs/s2-overview.md): S2 as a system: cube-to-sphere projection, exact quad hierarchy (4 children exactly tile every parent), 31 levels, Hilbert-curve cell IDs, and quadrilateral cells that are not equal-area. ## quality - [Conversion Conformance Testing](https://etherdata.ai/geo-interop-kb/docs/conversion-conformance-testing.md): Machine-readable fixtures and property-based tests catch the conversions that only fail on pentagons, antimeridian cells, or near-polar geometry rather than on the common case. - [Conversion Quality Metrics](https://etherdata.ai/geo-interop-kb/docs/conversion-quality-metrics.md): Coverage, overreach, underreach, and Jaccard share the same numerator family but different denominators, so a geometry can score high on one and poorly on another simultaneously. ## concepts - [Conversion Profiles](https://etherdata.ai/geo-interop-kb/docs/conversion-profiles.md): Seven named profiles bundle a containment rule, resolution policy, circle mode, and weighting into a reusable recipe for a stated intent — they are defaults, not universal answers. - [Geographic Interoperability Model](https://etherdata.ai/geo-interop-kb/docs/geographic-interoperability-model.md): The six geographies that a single campaign passes through, why they must stay distinct, and why provenance has to survive every conversion. - [Geometry Normalization](https://etherdata.ai/geo-interop-kb/docs/geometry-normalization.md): Every downstream H3 conversion assumes closed, correctly wound, EPSG 4326 rings with no self-intersections, and normalization is the single gate that must guarantee it. - [Requested Vs Executed Geography](https://etherdata.ai/geo-interop-kb/docs/requested-vs-executed-geography.md): A campaign geography passes through six distinct forms between the ask and the outcome, and most reporting disputes trace back to two of those forms being silently conflated. - [Resolution Selection](https://etherdata.ai/geo-interop-kb/docs/resolution-selection.md): Choosing an H3 resolution trades boundary fidelity against inventory size, computation cost, and privacy risk, and the right tradeoff depends on the intent, not on a fixed rule. ## h3-to-execution - [Google-Style Point-Radius Execution](https://etherdata.ai/geo-interop-kb/docs/google-style-point-radius-execution.md): Executing an H3 cell set on a platform that only accepts many independent point-and-radius targets, one circle per cell - [H3 Cell Set to Optimized Circle Cover](https://etherdata.ai/geo-interop-kb/docs/optimized-circle-cover.md): A heuristic greedy cover that replaces a target H3 set with fewer point+radius circles under a bounded overreach — experimental, not an optimal solver. - [H3 To Circumscribed Circle](https://etherdata.ai/geo-interop-kb/docs/h3-to-circumscribed-circle.md): Approximating an H3 cell with the smallest centered disk that fully contains it, guaranteeing coverage at the cost of overlap with neighboring cells - [H3 To Equal Area Circle](https://etherdata.ai/geo-interop-kb/docs/h3-to-equal-area-circle.md): Approximating an H3 cell with a disk of the same area for reach and planning estimates, with no containment guarantee in either direction - [H3 To Exact Polygon](https://etherdata.ai/geo-interop-kb/docs/h3-to-exact-polygon.md): Rendering an H3 cell set as its true GeoJSON boundary, with no radius approximation and no area lost or gained - [H3 To Inscribed Circle](https://etherdata.ai/geo-interop-kb/docs/h3-to-inscribed-circle.md): Approximating an H3 cell with the largest centered disk that stays fully inside it, for platforms that only take a point and a radius ## semantics - [H3 Compaction And Uncompaction](https://etherdata.ai/geo-interop-kb/docs/h3-compaction-and-uncompaction.md): Losslessly replacing a complete set of sibling cells with their parent, and the exact round-trip property that makes it safe to use for storage and target-count optimization. - [Inclusion And Exclusion Semantics](https://etherdata.ai/geo-interop-kb/docs/inclusion-and-exclusion-semantics.md): How include and exclude geographies combine into one effective target, and why the combination has to happen in a single normalized cell space before anything else runs. - [Mixed H3 Resolutions](https://etherdata.ai/geo-interop-kb/docs/mixed-h3-resolutions.md): Why a set containing H3 cells from more than one resolution cannot be compared or subtracted until every cell is normalized to a single resolution. ## crosswalks - [H3 To Administrative Crosswalk](https://etherdata.ai/geo-interop-kb/docs/h3-to-administrative-crosswalk.md): Preserving every cell-to-region relationship a boundary crossing creates, instead of collapsing a straddling H3 cell to a single administrative owner. - [H3 To Platform Native Geography](https://etherdata.ai/geo-interop-kb/docs/h3-to-platform-native-geography.md): Mapping H3 cells to the opaque geo IDs a platform actually accepts, and recording the confidence and gaps that mapping introduces. ## platforms - [Platform Target Count Constraints](https://etherdata.ai/geo-interop-kb/docs/platform-target-count-constraints.md): Every execution platform imposes its own radius floors, target-count ceilings, and unsupported-geometry limits, and an H3 result set must be fitted to them, not assumed compatible. ## privacy - [Privacy And Minimum Aggregation](https://etherdata.ai/geo-interop-kb/docs/privacy-and-minimum-aggregation.md): Small-cell re-identification and device-trajectory exposure are geometry problems as much as policy problems, and the mitigations are enforceable at the conversion layer. ## Edge cases - [Edge-case catalogue](https://etherdata.ai/blog/geo-interop-kb/edge-cases) - 93 entries; each at https://etherdata.ai/geo-interop-kb/edge-cases/.md ## Structured data - [Full KB JSON](https://etherdata.ai/geo-interop-kb/kb.json) - [Search index](https://etherdata.ai/geo-interop-kb/search-index.json) ======================================================================== # FULL CONTENT ======================================================================== --- # Administrative Boundaries > The family of governmentally or institutionally defined partitions — country down to school district — and why the id you're handed is never the boundary itself. - **URL:** https://etherdata.ai/blog/geo-interop-kb/docs/administrative-boundaries - **Category:** geometries - **Source geometry:** admin_country, admin_state, admin_county, postal_code, census_geo, dma - **Edge cases:** stale-boundaries, duplicated-region-ids - **Related:** administrative-polygon-to-h3, h3-to-administrative-crosswalk, geometry-catalogue, platform-identifiers --- Administrative boundaries are the geometries that some authority — a national mapping agency, a census bureau, a media-measurement vendor, a utility, a publisher — has declared to exist, and that authority's decision is the geometry's only source of truth. This distinguishes the family from arbitrary polygons: a county line does not move because a better model draws it differently, and disputing it means petitioning the agency, not re-running an algorithm. Every member of this family is consumed as a **partition** — reporting, budgeting, tax, and compliance logic downstream assumes every point on Earth belongs to exactly one instance of a given level, which is why the conversion path for this family (below) defaults to producing a true partition rather than a proportional split. | | | |---|---| | Cardinality | Partition — every location belongs to exactly one instance per level | | Governed by | A national, statistical, or licensing authority per level (Census, USPS, Nielsen, ISO) | | Not a geometry | The id (FIPS, GEOID, DMA code, ZIP) — only the resolved boundary is | | Converts via | Center-contained or max-overlap polyfill into a cell-to-region partition | ## Members | Member | What it is | Typical id / namespace | |---|---|---| | Country | National boundary | ISO 3166-1 alpha-2/3 | | State / province | First-level subdivision | FIPS (US), ISO 3166-2 | | County | Second-level subdivision | 5-digit FIPS (US) | | Municipality / borough | City, town, or borough-level unit | Local/vendor id, often no federal standard | | Postal code | USPS/national mail delivery area | ZIP (US), postcode (UK) | | Census geography | Tract, block group, block | GEOID (US Census/TIGER) | | DMA / media market | Nielsen-defined media market | DMA code (proprietary) | | NUTS or equivalent | EU statistical region hierarchy | NUTS 1/2/3 code | | Electoral district | Voting/representation boundary | State/national election-authority id | | School district | Education-service boundary | NCES district id (US) | | Service territory | Utility or franchise service area | Utility-internal id | | Publisher-defined market | A media owner's own named market | Publisher-internal label | ## Required metadata | Field | Why it's required | |---|---| | Boundary source | Different agencies draw the "same" boundary differently (e.g. Census TIGER vs. a commercial vendor's county file) | | Vintage | Every level in this family is redrawn on its own schedule — counties rarely, DMAs and postal areas often | | CRS | Vendor shapefiles frequently arrive in a projected CRS and must be reprojected to EPSG:4326 before any H3 work | | Id namespace | "12" means nothing without knowing whether it's a FIPS state code, a DMA code, or a publisher's internal label | ## Common risks > **Note:** `"36061"` is a string. It is not a shape until it is resolved against a specific boundary file at a specific vintage. Two systems holding the same FIPS code can disagree about the polygon it resolves to if one is running a 2020 vintage and the other a 2024 vintage — the id never changes even when the boundary under it does. This is the single most common source of silent misassignment in this family; see [requested vs. executed geography](/docs/requested-vs-executed-geography/). Beyond that structural risk, four failure modes recur across this family: **vintage drift** (DMA and postal boundaries move on cycles measured in years, not decades — a crosswalk built against a stale vintage misassigns every cell near a boundary that has since shifted); **leading-zero loss** (FIPS and GEOID strings — `"01001"` for Autauga County, Alabama — are numeric-looking but not numbers, and an integer cast silently truncates the leading zero, producing a code that either doesn't match anything or matches the wrong region); **ZIP-is-not-a-polygon** (a US ZIP code is a USPS delivery-route abstraction with no authoritative boundary of its own — any "ZIP polygon" in circulation is a third party's ZCTA approximation, and must be labeled and sourced as such rather than treated as ground truth); and **licensing** (DMA boundaries specifically are Nielsen's proprietary IP — redistributing a raw DMA shapefile, or a cell-to-DMA crosswalk derived from one, without the appropriate license is a legal exposure, not just a data-quality one). ## How it converts to H3 Administrative boundaries are polyfilled into H3 as a partition — one region id per cell, by center-containment or max-overlap assignment — on [administrative polygon to H3](/docs/administrative-polygon-to-h3/). When the requirement is proportional splitting instead (population or spend divided across regions a cell straddles), use the weighted variant on [H3 to administrative crosswalk](/docs/h3-to-administrative-crosswalk/). If what you're actually holding is a bare id rather than a polygon, that is not this family at all — see [platform identifiers](/docs/platform-identifiers/). --- # Administrative Polygon To H3 > Converting counties, states, DMAs, census geographies, and postal areas into H3 cells while preserving the partition property those units are supposed to have. - **URL:** https://etherdata.ai/blog/geo-interop-kb/docs/administrative-polygon-to-h3 - **Category:** source-to-h3 · **Exactness:** approximate - **Source geometry:** admin_county, admin_state, dma, census_geo, postal_code - **Destination geometry:** h3_cell_set - **Edge cases:** stale-boundaries, duplicated-region-ids, multipart-geometries - **Related:** arbitrary-polygon-to-h3, h3-to-administrative-crosswalk, geometry-normalization, mixed-h3-resolutions --- approximate ## Purpose Administrative and statistical boundaries — counties, states, DMAs, census tracts, ZCTAs, and postal-code areas — are usually consumed as a **partition**: every H3 cell should belong to exactly one region, because reporting, budgeting, and compliance logic downstream assumes non-overlapping buckets. This page covers the conversion path that preserves that property, and contrasts it with the retention-oriented crosswalk that does not. ## Source geometry and destination geometry Source geometry is one of `admin_county`, `admin_state`, `dma`, `census_geo`, or `postal_code` — each supplied as a polygon or multipolygon in a vendor shapefile, GeoJSON file, or database geometry column, keyed by an identifier (FIPS, GEOID, DMA code, ZIP/ZCTA). Destination geometry is an `h3_cell_set`: a set of H3 cells at a stated resolution, each tagged with exactly one region id. ## Exactness class This conversion is **approximate**: no assignment rule reproduces the source polygon's area exactly, and the boundary of the assigned cell set will not coincide with the source boundary at any resolution short of the coordinate precision of the original survey. Two different, valid assignment rules (below) produce two different cell sets from the same input. ## Containment rule and boundary behavior Two assignment rules are in scope, and they answer different questions: | | | |---|---| | Center-contained (partition) | A cell belongs to region R if and only if the cell's center point falls inside R's polygon. Every cell is assigned to at most one region by construction, so the resulting cell set is a true partition — the property callers usually want from admin boundaries. | | Max-overlap assignment (crosswalk) | A cell that straddles two or more regions is assigned to whichever region contains the largest share of the cell's area. Used when a cell must be labeled but only touches a region's edge — this also yields a partition, but by area majority rather than center. | Center-contained is the default for reporting-grade partitions because it is deterministic and reproducible from the polygon and the H3 grid alone, without needing an area computation per cell. Max-overlap is used when the polygon boundary runs close to many cell centers (common at res 8+ near jagged county lines) and center-containment would otherwise assign a disproportionate number of boundary cells to whichever side of the line the grid happens to bias toward. Neither rule should be confused with the [weighted crosswalk](/docs/h3-to-administrative-crosswalk/), which deliberately breaks the partition property: it retains every `(cell_id, region_id)` pair a cell touches, with an intersection-area fraction per pair, so that population or spend can be split proportionally across regions instead of forced into one. > Figure (admin-crosswalk): Max-overlap partition of two adjacent regions at R8; each cell to its argmax region (blue=west, pink=east). ## Resolution behavior Higher resolution cells track the source boundary more closely because cell area shrinks roughly sevenfold per resolution step, shrinking the maximum possible per-cell disagreement between center-containment and the true polygon edge. At res 6, a single mis-assigned boundary cell can misplace several square kilometers; at res 9, the same error is bounded to a few hectares. Multipart admin units (a county with an offshore island, a DMA split by a lake) need per-part polyfilling — polyfilling the multipolygon as a whole can silently drop small parts if the polyfill implementation does not iterate rings. ## Units and CRS Source polygons must be normalized to EPSG:4326 before polyfilling; areas for overlap computation are computed as spherical (haversine-consistent) m². Vendor shapefiles delivered in a projected CRS (state plane, Albers) must be reprojected first — reprojection error is typically under 1 meter for CONUS-scale admin polygons but should be checked, not assumed, for Alaska, Hawaii, and territories. ## Algorithm ```ts // Partition: center-contained, one region per cell const partitionCells = polygonToH3(countyPolygon, { resolution: 8, mode: "center", }); // Partition by area majority, for boundary-heavy geographies const majorityCells = maxOverlapAssignment(candidateCells, regionPolygons, { resolution: 8, }); ``` The same conversion with the Python bindings (`h3-py` v4): ```python from shapely.geometry import Polygon # Center-contained partition: one polygon_to_cells call per region region_cells = { region_id: h3.polygon_to_cells(h3.LatLngPoly(ring), res=8) for region_id, ring in region_rings.items() # ring = [(lat, lng), ...] } # Max-overlap assignment for cells straddling more than one region region_shapely = { region_id: Polygon([(lng, lat) for lat, lng in ring]) for region_id, ring in region_rings.items() } candidate_cells = set().union(*region_cells.values()) assignment = {} for cell in candidate_cells: boundary = Polygon([(lng, lat) for lat, lng in h3.cell_to_boundary(cell)]) best_region, best_area = None, 0.0 for region_id, poly in region_shapely.items(): area = boundary.intersection(poly).area if area > best_area: best_region, best_area = region_id, area assignment[cell] = best_region # argmax over intersection area ``` The tested reference implementation for this conversion is the TypeScript in `lib/`; `h3-py` has no built-in `maxOverlapAssignment`, so the Python above reproduces the same argmax-over-intersection-area loop with `shapely`. ## Parameters Resolution (int, typically 7–9 for county/DMA-scale work), assignment mode (`center` or `max-overlap`), boundary vintage (the effective date of the source file), and the region id field to preserve. ## Outputs A cell-to-region table: `cell_id`, `region_id`, `resolution`, `assignment_mode`, `boundary_vintage`. No overlap fraction is stored, because by construction each cell has exactly one region. ## Quality metrics Compute `coverage_ratio` and `overreach_ratio` per region against the source polygon; a well-behaved center-contained partition typically shows `coverage_ratio` in the 0.90–0.98 range with `overreach_ratio` near zero, since center-containment cannot assign a cell whose center lies outside the polygon. Check `jaccard` per region as a single combined figure for boundary tightness across resolutions. ## Edge cases FIPS and GEOID codes are numeric strings with meaningful leading zeros (`"01001"` for Alabama, Autauga County); casting them to integers during a join silently corrupts the key — this is the single most common cause of "missing counties" bugs. Boundary vintage matters: county lines are stable, but DMA boundaries and ZCTA definitions change year over year ([stale-boundaries](/docs/geometry-catalogue/)), so a cell-to-region table built from a 2019 DMA file will misclassify cells near any boundary that moved since. Some vendor files carry [duplicated-region-ids](/docs/geometry-catalogue/) — the same GEOID appearing on two disjoint ring records for a single county, which is legal multipart geometry, not a duplicate to be deduplicated away. And a ZIP code is not a polygon: it is a USPS delivery-route abstraction with no authoritative boundary; any "ZIP polygon" in circulation is a third party's ZCTA approximation and should be labeled and sourced as such rather than treated as ground truth. ## Assumptions and limitations This conversion assumes the source file is a genuine partition of its parent geography (no gaps, no overlaps) before polyfilling — polyfilling cannot repair a source file that already double-counts territory. It also assumes callers need a partition; if the actual need is proportional weighting across regions, use the [arbitrary polygon](/docs/arbitrary-polygon-to-h3/) threshold/weighted path instead of forcing a single-region assignment. --- # Advertising Geographic Matching Semantics > Geometry alone does not define who gets targeted; the matching semantic, location source, and lookback window determine the audience as much as the shape does. - **URL:** https://etherdata.ai/blog/geo-interop-kb/docs/advertising-geographic-matching-semantics - **Category:** advertising - **Source geometry:** geofence, point_radius, trade_area, device_ping - **Destination geometry:** multipoint_audience - **Edge cases:** ip-derived-location, consent-precision, duplicate-observations - **Related:** requested-vs-executed-geography, privacy-and-minimum-aggregation --- ## The geometry is not the target Two platforms can be handed the identical polygon — the same vertices, the same CRS, the same H3 cell set — and deliver to materially different audiences. This is not a bug in one of the platforms; it is because a targeting request is not fully specified by its geometry. It is specified by a `GeographicTarget`: geometry plus a matching semantic, a lookback window, a location source, a confidence level, and whether the geometry includes or excludes. | | | |---|---| | geometry | The shape itself — a polygon, circle, or H3 cell set. Necessary but not sufficient. | | matchingSemantic | How a device or person is associated with the shape. See the seven values below. | | lookbackWindow | How far back a presence event still counts as a match, e.g. 30 days, 90 days, or none (real time only). | | locationSource | GPS, IP, publisher-declared, cell tower, or a probabilistic model. Determines the effective precision, independent of the geometry's own precision. | | confidence | A score in 0 to 1 the platform assigns to a given location observation; low-confidence observations may be silently dropped or silently included. | | inclusionOrExclusion | Whether the shape adds or removes eligibility. An excluded shape with weak matching leaks eligible devices back in. | ## The seven matching semantics | | | |---|---| | physical_presence | The device was observed inside the geometry during the campaign's active window. Requires a real-time or near-real-time location ping. | | recent_presence | The device was observed inside the geometry within the lookback window, not necessarily during the active campaign. A 500m geofence with a 30-day lookback matches people no longer there. | | home_location | The device's inferred home is inside the geometry, from a separate home-location model (typically overnight ping clustering), independent of any daytime movement. | | work_location | Same as home_location but for an inferred workplace cluster, typically daytime-weekday pings. | | interest | No location observation at all — the match is behavioral or declared (e.g. a user follows a page tagged to that region). The geometry is a proxy, not a location event. | | presence_or_interest | A platform-defined union of a presence signal and an interest signal, most common on walled-garden platforms that blend both to maximize match rate. | | platform_defined | The platform does not disclose which of the above it uses, or uses an internal blend that varies by inventory source. Treat as unknown precision until proven otherwise. | > **Note:** A DSP set to `physical_presence` with a 0-day lookback and a social platform set to `presence_or_interest` with a 30-day lookback, both targeting the same circle, are not comparable line items. One counts people who were there today; the other counts anyone who was there in the last month plus anyone who merely expressed interest in the area. Reporting them under one "targeted audience" number is a category error, not a rounding difference. ## Location source changes the effective geometry `locationSource` interacts with the geometry independent of the matching semantic: - **GPS-derived**: typically 5–20 m accuracy outdoors, degrading indoors and in urban canyons. The geometry is matched close to as-drawn. - **IP-derived**: resolves to an ISP allocation block, often centroid-biased to a city or zip centroid rather than the device's true location — a device can match a 1 km geofence from tens of kilometers away if the IP registry entry is stale or the ISP routes traffic through a distant node. See [ip-derived-location](/docs/coordinate-and-crs-failures/). - **Publisher-declared**: the location is asserted by the publisher (a weather app's "current city," a news site's regional edition) with no device-level signal at all. Matches at the publisher's declared granularity regardless of the requested geometry's precision. - **Cross-device**: a household or person graph links a matched device to other devices never directly observed inside the geometry; the executed audience is provably larger than the set of devices that produced a location event. ## Lookback window and duplicate eligibility A device observed inside overlapping geofences during the lookback window is eligible under both. If reporting sums audience per fence rather than taking the union, the same device is counted twice — this is `duplicate-observations`, and it inflates reach numbers proportional to fence overlap and lookback length. The mitigation is to deduplicate by a stable device key within the reporting window before aggregation, and to report the union's cardinality, not the sum of per-fence counts. ## Treatment/control contamination and excluded-area leakage For measurement, not just delivery, matching semantics create contamination paths that geometry alone does not reveal: - A `recent_presence` semantic with a long lookback pulls people who moved out of the control area into treatment eligibility retroactively, and vice versa. - An excluded area (a competitor's exclusive zone, a control market) built from `physical_presence` still leaks devices matched by `home_location` or `interest`, because those semantics do not require the device to have been physically inside the excluded polygon at all. - Bidstream truncation: real-time bidstream feeds frequently omit or coarsen the location field below a documented threshold to save payload size or to satisfy consent constraints, so a `physical_presence` match computed from bidstream is silently biased toward the subset of impressions that still carried usable coordinates. ## Consent-based precision reduction Consent state (see [privacy-and-minimum-aggregation](/docs/privacy-and-minimum-aggregation/)) can force a platform to degrade `locationSource` precision or drop the location field entirely for a given user, without changing the requested geometry. The safe behavior is to treat a consent-reduced observation as a lower-confidence match — never to up-sample it back to the original geometry's resolution, and never to silently exclude it from reach denominators without disclosure. ## Assumptions and limitations This model assumes the platform discloses `matchingSemantic` and `lookbackWindow` per line item; when a platform reports only `platform_defined`, downstream comparison across platforms is not valid without an independent audit, and any coverage or overlap metric computed from the geometry alone should be labeled as an upper bound on delivered match rate, not the match rate itself. --- # Antimeridian Handling > Geometries and circles that cross the ±180° meridian wrap incorrectly under planar longitude math and must be split or unwrapped before any H3 or area operation. - **URL:** https://etherdata.ai/blog/geo-interop-kb/docs/antimeridian-handling - **Category:** systems · **Exactness:** exact - **Source geometry:** geofence, point_radius, trade_area, bounding_box - **Destination geometry:** h3_cell_set - **Edge cases:** antimeridian - **Related:** h3-to-exact-polygon, bounding-box-to-h3, geometry-normalization --- exact ## Purpose Longitude is a coordinate on a circle, not a line, and every operation here that treats it as a line — ring winding, bounding-box construction, buffer generation, planar intersection — breaks silently for any geometry crossing the ±180° meridian. This page states the exact detection test and exact mitigation so "handle the antimeridian" is never an unstated assumption. ## The problem A polygon with vertices at longitude 179.5° and −179.8° is 0.7° wide across the meridian, but a planar algorithm reading those numbers on a continuous line computes a span of 359.3° — effectively the entire globe minus a sliver. The same failure hits point-radius circles (a circle centered at 179.9° with a 50 km radius has an arc crossing 180°) and bounding boxes (a box whose "west" edge is numerically larger than its "east" edge, e.g. west = 170°, east = −170°, is a valid wrapping box, not an invalid one). ## Source geometry and destination geometry Source geometry is any polygon, point-radius circle, or bounding box normalized to EPSG:4326 decimal degrees. Destination geometry is the same geometry type, corrected, or an `h3_cell_set` if the corrected geometry is then polyfilled. This page is a precondition for [geometry-normalization](/docs/geometry-normalization/), not an alternative to it — correction must run before self-intersection repair and before polyfilling, since polyfilling an unsplit, meridian-crossing ring returns either zero cells or every cell on the globe depending on which winding-order edge case the library hits. ## Exactness class Exact. Correction is a coordinate-space transform, not an approximation: the corrected geometry represents the identical physical region as the source, with no coverage or area error introduced. ## Detection | | | |---|---| | Longitude span check | For a ring, compute max(lng) − min(lng) using raw signed longitudes. A span greater than 180 degrees indicates the ring crosses the antimeridian (a correctly-behaved ring never legitimately spans more than 180 degrees in a single unwrapped pass). | | Sign change across consecutive vertices | Walk the ring in order; if consecutive vertices flip sign (e.g. 179.6 then -179.9) AND the absolute difference exceeds 180 degrees, that edge crosses the meridian. | | Bounding box west greater than east | A bounding box where west > east in raw signed degrees (e.g. west=170, east=-170) is a valid antimeridian-wrapping box, not a malformed one — reject any validator that treats west > east as an error. | | Circle center near ±180° | For point-radius, flag any circle whose center longitude is within radius/111320 degrees (approximate meters-per-degree at the equator) of ±180°, since its geodesic buffer may cross regardless of the exact center value. | ## Mitigation | | | |---|---| | Split at the antimeridian | Cut the ring into two (or more) closed rings at longitude = 180 / -180, producing a valid multipolygon where each part has an unwrapped, non-crossing longitude range. This is the standard GeoJSON-correct representation. | | Great-circle densification before splitting | Insert intermediate vertices along the true geodesic between the two vertices that straddle the meridian, so the split point is computed from the actual edge path rather than a straight planar interpolation, which is itself distorted near the pole-adjacent regions of a wide crossing. | | Longitude unwrapping for local operations | For operations confined to a small window around the crossing (e.g. computing area or a local buffer), shift longitudes by +360 degrees wherever they are negative, so the whole geometry lies in a single continuous range (e.g. 179 to 181 instead of 179 to -179). Unwrap only for the scope of the local computation; never persist unwrapped coordinates as the canonical representation. | | h3-js isGeoJson handling | h3-js's `polygonToCells` accepts a `GeoJsonPolygon` flag that changes ring-crossing behavior at the meridian; confirm which convention (raw signed degrees vs. pre-split multipolygon) the call site expects before passing a crossing ring directly, since passing an unsplit ring to a function expecting pre-split input silently returns the wrong hemisphere's cells. | ## Resolution behavior Correction is resolution-independent — a coordinate transform applied once, before polyfilling, whose correctness does not change with the H3 resolution chosen downstream. What does change with resolution is the number of cells near the meridian whose own boundary crosses ±180°: `cellToBoundary` output for those cells needs the same split/unwrap logic before being rendered or intersected, since H3 cell boundaries are not automatically corrected for meridian crossing by all consumers. ## Units and CRS EPSG:4326 decimal degrees throughout. Splitting and densification run in geodesic (great-circle) space, not a planar-projected CRS — projecting first would require a projection centered away from the crossing, which reintroduces the same problem at a different meridian. ## Algorithm ```ts // normalizePolygon detects span > 180deg and splits into a multipolygon // with each part's longitudes in a continuous, non-wrapping range. const normalized = normalizePolygon(rawPolygon); // Polyfill each part independently; union the resulting cell sets. const cells = normalized.parts.flatMap((part) => polygonToH3(part, { resolution: 8, mode: "intersect" }), ); ``` ## Parameters None beyond the input geometry — a detection-and-correction pass, not a tunable conversion. The only implicit parameter is the densification sample count used to insert intermediate vertices before computing the split point. ## Outputs A GeoJSON multipolygon (or corrected bounding box / circle) with no ring spanning more than 180° of raw longitude, ready for downstream normalization and polyfilling. ## Quality metrics `coverage_ratio` and `jaccard` computed against the source, both of which should equal 1.0 for a correct split (the correction is exact, so any deviation indicates a bug in the split/densification, not an accepted approximation). ## Edge cases Antimeridian is itself the edge case this page documents; it compounds with [self-intersections](/docs/geometry-normalization/) when a source polygon both crosses the meridian and has invalid winding — splitting must run first, since repairing winding on an unsplit ring is undefined. ## Assumptions and limitations This page assumes the source geometry is otherwise valid GeoJSON — closed rings, no self-intersections away from the meridian. It does not address pole-adjacent densification distortion at very high latitudes near the crossing, best handled by increasing the sample count, not a different algorithm. ## Illustration — naive fill vs antimeridian split > Figure (antimeridian): A box spanning 160°E to 160°W: the naive planar fill (pink) wraps the wrong way around the globe; the correct result splits into two strips hugging ±180 (green). --- # Arbitrary Polygon To H3 > The four containment modes for polyfilling any polygon into H3 cells, when to use each, and how to retain overlap for downstream weighting. - **URL:** https://etherdata.ai/blog/geo-interop-kb/docs/arbitrary-polygon-to-h3 - **Category:** source-to-h3 · **Exactness:** approximate - **Source geometry:** trade_area, geofence, parcel - **Destination geometry:** h3_cell_set - **Edge cases:** narrow-polygons, tiny-polygons, touching-only, simplified-boundaries, holes - **Related:** administrative-polygon-to-h3, h3-to-administrative-crosswalk, conversion-quality-metrics, h3-pentagons, geometry-normalization --- approximate ## Purpose Trade areas, geofences, and parcels arrive as free-form polygons with no partition guarantee and no administrative registry behind them. This is the general-purpose polygon-to-H3 conversion: every other polygon conversion in this knowledge base (administrative, buffered point-radius, corridor) reduces to this one after its own geometry-specific step. It exists in four containment modes because "is this cell in the polygon" has four different, equally legitimate answers. ## Source geometry and destination geometry Source geometry is one of `trade_area`, `geofence`, or `parcel` — a single polygon or multipolygon, normalized ([geometry-normalization](/docs/geometry-normalization/)) to EPSG:4326 with closed rings, correct winding, and repaired self-intersections before polyfilling. Destination geometry is an `h3_cell_set`, optionally paired with per-cell overlap fractions. ## Exactness class Approximate, by design and by mode: `full` and `intersect` are conservative and expansive respectively at the two ends of a spectrum, `center` is the cheapest and least biased single answer, and `threshold` is a tunable approximation with no single "correct" cutoff. ## Containment rule and boundary behavior | | | |---|---| | center | Include a cell if its center point lies inside the polygon. Cheapest to compute; no area math per cell. Coverage is close to the true area on average but can be biased for any single small polygon. | | full | Include a cell only if the entire cell is contained in the polygon. Never overreaches — every included cell's full area is inside the source — but always underreaches at the boundary, since partial boundary cells are dropped entirely. | | intersect | Include a cell if it touches the polygon at all, even by one square meter. Never underreaches — the union of included cells always covers the polygon — but always overreaches, sometimes substantially, at ragged boundaries. | | threshold | Include a cell if its fractional overlap with the polygon meets or exceeds a caller-supplied threshold t. Tunable between full's conservatism and intersect's expansiveness; the only mode requiring a per-cell area computation before the containment decision. | ``` center: o--o--o--o full: [--][--] | polygon | | polygon | o--o--o--o [--][--] (cell IN if its o is inside) (cell IN only if fully inside) intersect: [##][##][##] threshold: [##][--][xx] | polygon | | polygon | (xx below t, dropped) [##][##][##] [##][ >=t ][xx] (any touch => IN) (area fraction >= t => IN) ``` The threshold rule is the only one with a closed-form test per cell: $$ \frac{\text{intersection area}(cell, \text{polygon})}{\text{cell area}} \ge t $$ A common convention is `t = 0.5`, which behaves like a tie-break between `full` and `intersect`, but `t` is a caller parameter, not a constant — a compliance use case that must never overreach should push `t` toward 1.0 (converging on `full`), while a reach-maximizing use case should push it toward a small positive value (converging on `intersect` but excluding cells that only graze the boundary at a single point). > Figure: center · 89.9% coverage, 9% overreach > Figure: full · 49.4% coverage, 0% overreach > Figure: intersect · 100% coverage, 68% overreach > Figure: threshold t=0.5 · 20 cells ## Resolution behavior All four modes converge toward the true polygon area as resolution increases, because the maximum per-cell area error shrinks with cell size. `full` converges from below (`coverage_ratio` rising toward 1), `intersect` converges from above (`overreach_ratio` falling toward 0), and `center` and `threshold` oscillate around the true value with shrinking amplitude. For polygons smaller than a handful of cells at the chosen resolution, none of the modes converge usefully — see [tiny-polygons](/docs/geometry-catalogue/) below. ## Units and CRS EPSG:4326 input, normalized before polyfilling. Intersection and cell areas for `threshold` and for quality metrics are computed as spherical m² (haversine-consistent), not planar-projected m² — projecting to a local Cartesian frame before an area computation introduces its own distortion that should be measured, not assumed away, especially above 60° latitude. ## Algorithm ```ts const conservative = polygonToH3(tradeArea, { resolution: 9, mode: "full" }); const expansive = polygonToH3(tradeArea, { resolution: 9, mode: "intersect" }); const tuned = polygonToH3(tradeArea, { resolution: 9, mode: "threshold", threshold: 0.5, }); // Partition assignment when a cell could belong to more than one polygon const assigned = maxOverlapAssignment(tuned, [tradeAreaA, tradeAreaB], { resolution: 9, }); // Retention-oriented crosswalk: keep every touched region per cell const crosswalk = weightedCrosswalk(tradeArea, { resolution: 9 }); ``` The same conversion with the Python bindings (`h3-py` v4): ```python from shapely.geometry import Polygon, shape # center mode: h3-py's polygon_to_cells is center-containment only outer_ring = [(lat, lng) for lng, lat in trade_area_coords] # h3-py wants (lat, lng) poly = h3.LatLngPoly(outer_ring) center_cells = h3.polygon_to_cells(poly, res=9) # full / intersect / threshold: h3-py has no mode argument, so classify # each candidate cell's overlap area against the polygon ourselves. shapely_poly = Polygon([(lng, lat) for lat, lng in outer_ring]) candidates = h3.polygon_to_cells(poly, res=9) | { n for c in center_cells for n in h3.grid_disk(c, 1) } def classify(cell, mode, threshold=0.5): boundary = Polygon([(lng, lat) for lat, lng in h3.cell_to_boundary(cell)]) inter = boundary.intersection(shapely_poly).area if mode == "full": return inter == boundary.area if mode == "intersect": return inter > 0 if mode == "threshold": return (inter / boundary.area) >= threshold full_cells = {c for c in candidates if classify(c, "full")} intersect_cells = {c for c in candidates if classify(c, "intersect")} tuned_cells = {c for c in candidates if classify(c, "threshold", 0.5)} ``` The tested reference implementation for this conversion is the TypeScript in `lib/`; the Python above reproduces the same per-cell decisions with core `h3-py` calls plus `shapely` for area, since `h3-py` (like `h3-js`) only ships center containment natively. ## Parameters Resolution, containment mode, threshold `t` (mode `threshold` only), and whether output should be a partition (via `maxOverlapAssignment`) or a retained multi-region crosswalk (via `weightedCrosswalk`). ## Outputs For the four containment modes: a flat `h3_cell_set` at the stated resolution and mode. For `weightedCrosswalk`, one row per touched `(cell, region)` pair carrying `cell_id`, `source_region_id`, `intersection_area`, `cell_coverage_fraction` (intersection area over cell area), and `region_coverage_fraction` (intersection area over the source polygon's total area) — the fields needed to split a metric like population or spend proportionally across regions rather than assigning it to one. ## Quality metrics Report `coverage_ratio`, `overreach_ratio`, `underreach_ratio`, and `jaccard` per polygon per mode. `full` should show `overreach_ratio = 0` by construction; `intersect` should show `underreach_ratio = 0` by construction — if either is violated, the polyfill implementation has a bug, not the polygon. ## Edge cases Narrow polygons (a road-adjacent strip, a thin trade-area sliver) can be narrower than a cell's diameter at coarse resolutions, causing `full` to return zero cells while `center` and `intersect` still return a thin one-cell-wide line — always sanity-check `full` output is non-empty before trusting it downstream. Tiny polygons (sub-cell-area parcels) hit the same failure for `full` and produce a single, disproportionately large cell for `center`; `threshold` with a low `t` is usually the least-bad default here. Touching-only polygons (two trade areas that share a boundary but do not overlap) can still both claim the same boundary cell under `intersect`, which is correct per the rule but must be resolved with `maxOverlapAssignment` if a partition is required. Simplified boundaries (Douglas-Peucker-reduced trade areas from a mapping SDK) shift the true edge by the simplification tolerance, which should be recorded and treated as an additional uncertainty band on top of the containment mode's own error. Holes (a trade area with an excluded interior ring, e.g. a competitor's exclusive zone) must be respected by the polyfill implementation — a naive polyfill that ignores interior rings will silently include cells the source explicitly excludes. ## Assumptions and limitations This conversion assumes the input polygon has already been normalized — self-intersections repaired, rings closed and correctly wound — since polyfilling a malformed polygon produces an undefined or silently wrong cell set rather than an error. It also assumes callers know which of the four modes they need before running the conversion: switching modes after the fact on an already-polyfilled cell set is not possible without re-running against the source polygon. --- # Arbitrary Polygons > Operator-drawn or model-generated shapes with no external authority governing their boundary — trade areas, geofences, parcels, and the risks that come from having no ground truth to check against. - **URL:** https://etherdata.ai/blog/geo-interop-kb/docs/arbitrary-polygons - **Category:** geometries - **Source geometry:** trade_area, geofence, parcel - **Edge cases:** self-intersections, holes, narrow-polygons, tiny-polygons, simplified-boundaries - **Related:** arbitrary-polygon-to-h3, geometry-normalization, administrative-boundaries, point-radius-geometries --- Arbitrary polygons are shapes with no external authority: nobody can look up "the" boundary the way a county line can be looked up in a TIGER file, because the boundary was drawn — by a human digitizing a delivery zone, by a drive-time model, by a customer-catchment algorithm — for a specific purpose and exists only in the system that drew it. This is the defining difference from [administrative boundaries](/docs/administrative-boundaries/): there is no vintage to reconcile against, because there is no canonical version to reconcile with. The tradeoff is flexibility for verifiability — an arbitrary polygon can be regenerated at will, but nothing external confirms it is correct. | | | |---|---| | Cardinality | No partition guarantee — polygons may overlap or leave gaps by design | | Governed by | Whoever drew or generated it — no external authority to check against | | Not a geometry | A parcel APN or territory label — the shape lives in the polygon file, not the code | | Converts via | Repair (self-intersection, holes) then polyfill under center/full/intersect/threshold | ## Members | Member | What it is | Typical generation method | |---|---|---| | Trade area | A store's modeled customer catchment | Drive-time isochrone or gravity model | | Delivery area | A courier or fulfillment service boundary | Manual digitization or routing-engine output | | Geofence | A presence-targeting or measurement polygon | Manual digitization (KML/GeoJSON) | | Venue campus | A stadium, mall, or airport footprint | Manual digitization from imagery | | Store catchment | Observed or modeled visit-origin area | Visit-data clustering or isochrone | | Parcel | A legal land boundary | Assessor / cadastre survey | | Regulatory zone | A jurisdiction-drawn compliance area (e.g. emissions zone) | Regulatory-body digitization | | Weather polygon | A storm warning or advisory area | Meteorological model output | | Custom sales territory | An internally defined rep or region boundary | Manual assignment, often built from admin unions | | Exclusion zone | An area explicitly carved out of a target set | Manual digitization or set subtraction | ## Required metadata | Field | Why it's required | |---|---| | Generation method | Determines how much to trust the boundary — a surveyed parcel and a hand-drawn geofence carry very different confidence | | Vintage / generated-at timestamp | A trade area or catchment is a model output tied to the data it was fit on; it goes stale as conditions change, even with no external redraw event | | CRS | Especially important for parcel data from assessors, which is frequently delivered in a state-plane or other projected CRS | | Author / purpose | A geofence drawn for measurement and one drawn for targeting may look identical but license and privacy handling differ | ## Common risks Because nothing external validates an arbitrary polygon, geometric defects that a surveyed boundary would never contain show up routinely here: **self-intersection** (bowtie rings, most often from manual digitization or a model artifact, which make area and containment undefined until repaired — see [geometry normalization](/docs/geometry-normalization/)); **holes** (an interior ring — a courtyard carved out of a campus polygon, a competitor's parcel excluded from a territory — that must be passed through to the filler intact, not silently filled over); **narrow slivers and tiny polygons** (a delivery area a few hundred meters wide, or a parcel smaller than a single cell at the working resolution, can legitimately produce zero center-contained cells, which is a correct result of the containment rule, not a bug); and **simplification drift** (a boundary run through Douglas-Peucker-style simplification for file-size reasons shifts vertices enough to change which cells qualify, especially at higher resolutions where cell edges are shorter than the simplification tolerance). A parcel's assessor identifier (an APN) is worth flagging on its own: it identifies a legal record, and the polygon it's joined to may lag a subdivision, merge, or boundary adjustment recorded at the assessor but not yet reflected in the geometry file — the id and the shape can drift apart even within this family, in the same way an administrative id can outlive its boundary. ## How it converts to H3 All ten members above share one conversion path: buffer or otherwise normalize the polygon, then polyfill under `center`, `full`, `intersect`, or `threshold` containment. See [arbitrary polygon to H3](/docs/arbitrary-polygon-to-h3/) for the full containment-mode comparison and the repair steps for self-intersections and holes. A shape that is described as a fixed radius around a point — "3 miles around this store" — is not an arbitrary polygon even if a system stores it as one; it belongs to [point-radius geometries](/docs/point-radius-geometries/), and forcing it through this path discards the radius-units question that family forces explicitly. --- # Bounding Box To H3 > Normalizing a west/south/east/north bounding box into a valid polygon before polyfilling, and the ordering and wraparound bugs that skip this step invites. - **URL:** https://etherdata.ai/blog/geo-interop-kb/docs/bounding-box-to-h3 - **Category:** source-to-h3 · **Exactness:** approximate - **Source geometry:** bounding_box - **Destination geometry:** h3_cell_set - **Edge cases:** antimeridian - **Related:** antimeridian-handling, geometry-normalization, arbitrary-polygon-to-h3 --- approximate ## Purpose A bounding box — `west`, `south`, `east`, `north` — is not itself a polygon; it is four numbers that imply one only under an assumed convention (geographic coordinates, `west < east`, no wraparound). Treating those four numbers as directly polyfillable without normalizing them first is the single most common source of silently wrong bounding-box conversions, particularly for any box that crosses the antimeridian. ## Source geometry and destination geometry Source geometry is `bounding_box`: a `(west, south, east, north)` tuple, typically produced by a map viewport, a vendor's coarse "service area," or a quick-and-dirty spatial filter. Destination geometry is an `h3_cell_set` after the box is converted to a proper polygon and polyfilled by the [arbitrary polygon](/docs/arbitrary-polygon-to-h3/) machinery. ## Exactness class Approximate, inheriting the exactness class of whichever polygon containment mode is applied after normalization — a bounding box carries no information about the "real" shape it is meant to approximate, so it should be treated as a crude proxy geometry, not a precise target, in any use case where the box is standing in for something else (a service area, a rough market boundary). ## Containment rule and boundary behavior Conversion is two steps, and the first step is where correctness is actually decided: 1. **Normalize**: turn `(west, south, east, north)` into a closed, correctly wound polygon in EPSG:4326. This requires resolving three ambiguities before a polygon can be constructed at all — coordinate validity, west/east ordering, and antimeridian crossing (below). 2. **Polyfill**: apply any of the four standard containment modes (`center`, `full`, `intersect`, `threshold`) to the normalized polygon, exactly as on the [arbitrary polygon](/docs/arbitrary-polygon-to-h3/) page. Normalization must check, in order: that `south < north` (a box with `south > north` is invalid, not merely reversed, since latitude does not wrap); whether `west > east` in a way that indicates antimeridian crossing rather than an invalid box (see edge cases); and that all four values fall within valid ranges (latitude in `[-90, 90]`, longitude in `[-180, 180]`) before any polygon is built. A rotated bounding box — one expressed in screen-space (pixel or tile) coordinates rather than geographic ones — is not a case this conversion handles at all; it must first be reprojected to geographic west/south/east/north, since a box that is axis-aligned on screen is not axis-aligned on the geographic grid except at very coarse zoom levels. ## Resolution behavior Ordinary polyfill resolution behavior applies once the box is a normalized polygon: the chosen containment mode's coverage error shrinks with finer resolution exactly as for any rectangular polygon. There is no box-specific resolution consideration beyond noting that a bounding box is usually a much coarser proxy for the caller's actual target than the resolution suggests — polyfilling a viewport bounding box at res 10 does not make the box a more accurate representation of anything, it just produces a finer-grained approximation of a shape that was already an approximation. ## Units and CRS Input coordinates must be EPSG:4326 decimal degrees. A box supplied in Web Mercator (EPSG:3857) tile bounds — common from map-viewport APIs — must be reprojected to geographic coordinates before normalization; failing to do so produces a box with plausible-looking but wrong degree values, especially near the poles where Web Mercator's distortion is most severe. ## Algorithm ```ts function bboxToH3(west, south, east, north, resolution, mode) { if (south > north) { throw new Error("invalid box: south must be <= north"); } // west > east signals antimeridian crossing, not an invalid box; // normalizePolygon must split the resulting polygon at +/-180. const rawPolygon = boxToPolygon(west, south, east, north); const polygon = normalizePolygon(rawPolygon); // splits at antimeridian if needed return polygonToH3(polygon, { resolution, mode }); } ``` The same conversion with the Python bindings (`h3-py` v4): ```python def bbox_to_h3(west, south, east, north, res): if south > north: raise ValueError("invalid box: south must be <= north") if west <= east: # Ordinary box: one LatLngPoly, counter-clockwise ring ring = [(south, west), (south, east), (north, east), (north, west)] return set(h3.polygon_to_cells(h3.LatLngPoly(ring), res)) # west > east signals antimeridian crossing, not an invalid box: split # into two boxes at +/-180 and union the polyfilled results. west_ring = [(south, west), (south, 180), (north, 180), (north, west)] east_ring = [(south, -180), (south, east), (north, east), (north, -180)] west_cells = h3.polygon_to_cells(h3.LatLngPoly(west_ring), res) east_cells = h3.polygon_to_cells(h3.LatLngPoly(east_ring), res) return set(west_cells) | set(east_cells) cells = bbox_to_h3(170, 10, -170, 20, res=6) # antimeridian-crossing box ``` `h3.polygon_to_cells` is center containment only, matching the `center` mode above; `full`/`intersect`/`threshold` need the same per-cell `shapely` classification described on the [arbitrary polygon](/docs/arbitrary-polygon-to-h3/) page, applied to whichever normalized (and, if needed, antimeridian-split) polygon results from `bbox_to_h3`'s normalization step. The tested reference implementation for this conversion is the TypeScript in `lib/`. ## Parameters The four box coordinates, H3 resolution, containment mode, and an explicit `assumeAntimeridianCrossing` flag or equivalent so that `west > east` is handled deliberately rather than inferred silently from the sign of the difference alone. ## Outputs An `h3_cell_set` at the stated resolution and mode; for a box that crosses the antimeridian, the output correctly includes cells on both the easternmost and westernmost sides of the +/-180 meridian rather than either an empty result or a result covering the wrong (much larger) hemisphere. ## Quality metrics `coverage_ratio`, `overreach_ratio`, and `jaccard` computed against the normalized polygon, not against the raw four-number box — since the box has no area itself, these metrics are only meaningful once it has been turned into a polygon. A useful box-specific diagnostic is polygon area versus the naive `(east - west) * (north - south)` product: a large discrepancy is a signal that antimeridian handling or high-latitude distortion is present and should be checked. ## Edge cases Antimeridian crossing is the dominant failure mode for this conversion. A box describing, for example, the Pacific region spanning `west = 170` to `east = -170` has `west > east` in raw numeric terms, but this is a valid 20-degree-wide box that crosses +/-180, not an inverted or invalid one; a naive implementation that assumes `west < east` will either construct a polygon spanning the wrong 340-degree remainder of the globe or throw on `south/north`-style validation logic misapplied to longitude. Correct handling splits the box into two polygons — one from `west` to `180`, one from `-180` to `east` — polyfills each independently, and unions the resulting cell sets; see [antimeridian handling](/docs/antimeridian-handling/) for the general treatment this conversion depends on. Distinguishing a genuinely invalid box (`west > east` due to a data error, with no crossing intended) from a legitimate antimeridian-crossing box requires either an explicit caller flag or a heuristic threshold (e.g. only treat `west > east` as crossing if the implied "short way" span is under some maximum width), and that heuristic should be documented wherever it is applied rather than left implicit, since it is a business-logic assumption, not a geometric fact. ## Assumptions and limitations This conversion assumes the four input numbers are genuinely geographic west/south/east/north bounds in EPSG:4326; screen-space, tile-space, or rotated bounding regions must be converted to that form first, and no amount of careful antimeridian handling here corrects for a box that was never geographic to begin with. It also assumes a normalized polygon is produced and validated before polyfilling — skipping normalization "because the box looks fine" is exactly the failure mode that antimeridian-crossing boxes exploit, since they look fine as four numbers and only fail once naively converted to a polygon. ## Illustration > Figure (bounding-box): A [W,S,E,N] envelope normalized to a polygon, then intersect-filled at R9. --- # Bounding Boxes > A [west, south, east, north] envelope — a map viewport or query bound, not a shape describing any real-world extent — and the ordering and antimeridian bugs that follow from treating it as one anyway. - **URL:** https://etherdata.ai/blog/geo-interop-kb/docs/bounding-boxes - **Category:** geometries - **Source geometry:** bounding_box - **Edge cases:** antimeridian - **Related:** bounding-box-to-h3, antimeridian-handling, arbitrary-polygons, coordinate-and-crs-failures --- A bounding box is an envelope — `[west, south, east, north]` — that describes the extent of a map viewport or a query's search bounds. It is not a shape describing any real place: nothing about a store's coverage or a region's outline is naturally rectangular, and a bounding box used as a targeting or reporting geometry is almost always a stand-in for "everything visible on screen right now" or "everything the caller asked to search within," not a deliberate footprint. Treating a bounding box as if it carries the same intent as a drawn polygon is the family's core conceptual risk; treating it as a trivial four-number rectangle with no edge cases is the family's core implementation risk. | | | |---|---| | Cardinality | One rectangle — never a partition, never a real footprint | | Governed by | Whatever client or query produced it — a viewport state or search parameter | | Not a geometry | A saved market label attached to a live, constantly-changing viewport rectangle | | Converts via | Corner-to-polygon construction (antimeridian-aware), then polyfill | ## Members | Member | What it bounds | Typical origin | |---|---|---| | Map viewport | The currently visible map extent | Client-side map state (pan/zoom) | | Search bounds | A geocoder or places-API query restriction | API request parameter | | API query envelope | A spatial filter on a data query | Query parameter | | Market extent | A rough rectangular stand-in for a market's footprint | Manually specified, often for a quick estimate | ## Required metadata | Field | Why it's required | |---|---| | CRS | Standard EPSG:4326 assumption, but verify — some mapping SDKs report viewport bounds in Web Mercator | | Screen vs. geographic | A screen-space rectangle and a true geographic envelope are easy to conflate but behave differently near the poles and the antimeridian | | Coordinate order convention | `[west, south, east, north]` is one common convention; `[minLng, minLat, maxLng, maxLat]` is numerically identical but callers still transpose lat/lng within it | ## Common risks **Antimeridian crossing**: when a box crosses ±180° longitude, `west` is numerically greater than `east` (e.g. `west=170, east=-170` for a box spanning the ±180° line near Fiji) — a naive box-to-polygon conversion that assumes `west < east` either produces an inverted rectangle or, worse, a box that silently spans the entire globe the wrong way. This is common enough near the Pacific that any bounding-box conversion needs an explicit antimeridian check, not an assumption that it won't happen; see [antimeridian handling](/docs/antimeridian-handling/). **Rotated boxes**: some viewport-derived bounds are not axis-aligned (a rotated map view), and a `[west, south, east, north]` tuple cannot represent rotation at all — if the source system supports rotation, the bounding box handed to a conversion may already be a lossy axis-aligned approximation of the true viewport, and that loss should be disclosed rather than treated as exact. **Screen vs. geographic conflation**: a screen/viewport rectangle changes with every pan and zoom and is not a stable geometry to persist or report against, while a geographic envelope (a market extent, a search radius expressed as a box) is meant to be stable; using a live viewport bound as if it were a saved market definition silently changes the "region" every time a user's map state changes. **West/east ordering assumptions**: distinct from the antimeridian case, some upstream systems deliver bounds already reordered (`min`/`max` rather than `west`/`east`), and assuming the tuple order matches the field names without checking produces a box that is numerically valid but geographically wrong. > **Note:** A market extent expressed as a bounding box is a rectangle that happens to contain the market, not a shape describing it — it will always include territory the market doesn't actually cover. Where a real market footprint is needed rather than a quick rectangular estimate, use [arbitrary polygons](/docs/arbitrary-polygons/) or [administrative boundaries](/docs/administrative-boundaries/) instead. ## How it converts to H3 A bounding box converts to H3 by first constructing a four-vertex polygon from its corners — handling the antimeridian sign flip explicitly — and then polyfilling that polygon under the same containment modes used for any other polygon. See [bounding box to H3](/docs/bounding-box-to-h3/) for the corner-construction algorithm and the antimeridian-safe splitting logic, and [coordinate and CRS failures](/docs/coordinate-and-crs-failures/) for the broader class of ordering and projection mistakes that a bounding box is especially prone to surfacing, since a four-number rectangle offers no redundancy to catch a transposed coordinate the way a denser polygon ring sometimes does. --- # Cell System Comparison > A matrix comparison of H3, S2, and Geohash across shape, hierarchy, equal-area, and containment behavior, and why cross-system conversion always goes through polygon union and re-fill. - **URL:** https://etherdata.ai/blog/geo-interop-kb/docs/cell-system-comparison - **Category:** systems · **Exactness:** approximate - **Source geometry:** h3_cell_set - **Destination geometry:** h3_cell_set - **Edge cases:** pentagons, mixed-resolutions - **Related:** h3-overview, h3-pentagons, mixed-h3-resolutions --- approximate ## Purpose This knowledge base treats H3 as the canonical interchange grid, but not every input or platform is H3-native, and the generic documentation model must not silently assume properties that are true of H3 but false of the other discrete global grid systems in use elsewhere in the ecosystem. This page is the comparison matrix and the explicit statement of what must never be assumed generically across systems. > **Note:** The AdCP (Ad Context Protocol) committee has accepted three cell systems as interoperable options: H3, S2, and quadkeys. This knowledge base recommends H3 as the default and treats it as the canonical interchange grid throughout; S2 and Geohash are documented here for contrast, not as alternates in active use. H3's hexagonal cells give every non-pentagon cell a uniform distance to its six neighbours and compact well into multi- resolution sets, which is why H3 rather than a quadrilateral or rectangular scheme is the standard. ## Comparison matrix | Dimension | H3 | S2 | Geohash | |---|---|---|---| | Cell shape | Hexagon (mostly), 12 pentagons per resolution | Quadrilateral | Rectangle | | Hierarchy model | Aperture-7; each parent has approximately 7 children | Quad; each parent has exactly 4 children | Base-32 prefix; each level adds 32x subdivision | | Children per parent | ~7 (not exact by area) | Exactly 4 | 32 | | Equal-area | Approximate (varies ~2x globally) | No | No (shrinks toward poles) | | Exact parent-child geometric containment | No — logical index arithmetic, children can spill past parent boundary | Yes — 4 children exactly tile the parent | Yes — prefix relationship is an exact containment | | Global coverage | Yes | Yes | Yes | | Index representation | 64-bit index, hex string | 64-bit cell id (Hilbert curve position) | Base-32 string | | Compaction support | Yes (`compactCells`/`uncompactCells`) | Yes | No | | Key caveats | 12 pentagons/resolution; face-crossing distortion; ~2x area variance | Not equal-area; 4 or more neighbours depending on position | Not equal-area; alternating aspect ratio; antimeridian/edge discontinuity between adjacent prefixes | ## What the generic model must not assume > **Note:** Every one of these is true of H3 specifically and false of at least one other system in this table. Code, prose, or a schema field that encodes any of these as a general "cell system" property rather than an H3-specific one is a latent bug the first time a non-H3 system reaches it. | | | |---|---| | Hexagons | Only H3 has hexagon-dominant cells, and even H3 is not all-hexagon. S2 is quadrilateral, Geohash is rectangular. | | Six neighbours | True only for non-pentagon H3 cells. S2 cells have 4 edge neighbours (more at corners); Geohash rectangles have 4. | | Uniform cell shape | H3 mixes hexagons and pentagons; the other two systems are each uniform in shape, but that shape differs system to system — 'uniform' does not imply 'hexagon.' | | H3 resolution numbering | H3's 0-15 scale, its aperture-7 area ratio per level, and its pentagon count per level are H3-specific. S2's 0-30 levels and Geohash's 1-12 character lengths are independent, with no direct level-to-level equivalence. | | Identical hierarchy semantics | 'Hierarchical' does not imply the same containment guarantee. S2 and Geohash give exact geometric/prefix containment; H3 gives logical-only containment. | | Exact parent-child containment | Only S2 (exact quad tiling) and Geohash (exact prefix containment) guarantee this. H3 explicitly does not — treating an H3 parent-child relationship as exact containment is a documented source of silent boundary error. | ## Cross-system conversion has no direct cell-to-cell map There is no lookup table mapping an H3 cell index directly to an S2 or Geohash index, because the three systems tile the sphere with different geometries at different resolutions — no cell boundary in one aligns exactly with any boundary in another. The only correct conversion path is geometric, not index-based: $$ \text{cells}_A \xrightarrow{\text{cellToBoundary (system A)}} \text{polygon} \xrightarrow{\text{union}} \text{region} \xrightarrow{\text{polyfill (system B)}} \text{cells}_B $$ 1. Extract each source cell's true boundary polygon in the source system (`cellToBoundary` or the equivalent for S2/Geohash). 2. Union the boundary polygons into a single region (or multipolygon, with antimeridian handling per [antimeridian-handling](/docs/antimeridian-handling/) if applicable). 3. Re-fill that region into the destination system using the destination system's own polyfill operation and containment mode (`center`, `full`, `intersect`, or the destination system's equivalent). This path necessarily introduces the same containment-mode approximation error documented throughout the source-to-H3 conversion pages, applied a second time if round-tripping — a cell set converted H3 to S2 and back is not guaranteed to reproduce the original exactly, and the discrepancy should be measured with `coverage_ratio` and `jaccard`, not assumed zero. ## Resolution behavior across systems Because the three numbering systems are independent, "matching resolution" across systems requires an explicit area-based correspondence (choosing the H3 resolution, S2 level, and Geohash length whose typical cell areas are closest) rather than assuming numeric equivalence — an H3 resolution 7 cell and an S2 level 13 cell are not defined to correspond by their numbering; any correspondence used here is an approximate area-match stated explicitly, not a system property. ## Quality metrics `coverage_ratio`, `overreach_ratio`, `underreach_ratio`, and `jaccard` computed on the re-filled destination cell set against the union polygon produced in step 2 above — the standard metrics apply unchanged across systems since they are defined on areas, not on index arithmetic. ## Edge cases `pentagons` affects H3 (12 per resolution) and requires per-cell shape handling rather than a hexagon-derived constant. `mixed-resolutions` applies within any single system's own hierarchy (see [mixed-h3-resolutions](/docs/mixed-h3-resolutions/)) and is a distinct concern from cross-system resolution correspondence discussed above — do not conflate mixing resolutions within H3 with matching resolutions across systems. ## References - H3 — Uber, [h3geo.org](https://h3geo.org/) - S2 Geometry — Google, [s2geometry.io](https://s2geometry.io/) ## Assumptions and limitations The matrix above reflects the generic system models registered in `data/cell-systems.yaml` as of this page's review date; exact resolution ranges, compaction support, and containment guarantees should be re-verified against each system's current documentation before relying on them for a production decision. ## Illustration — hexagon vs pentagon shape > Figure: ordinary hexagon (6 sides) > Figure: H3 pentagon (10 boundary vertices) The inner (green) and outer (amber) circles show each cell is only approximately regular; the pentagon's boundary carries extra vertices where it crosses an icosahedron edge. ## Illustration — the same area, three systems > Figure (systems-tiling): One ~1.2 km area tiled by H3 (hexagons), S2 (quads), and Geohash (rectangles) — real cells from each library. H3 is the AdCP-accepted default used throughout this knowledge base. --- # Conversion Conformance Testing > Machine-readable fixtures and property-based tests catch the conversions that only fail on pentagons, antimeridian cells, or near-polar geometry rather than on the common case. - **URL:** https://etherdata.ai/blog/geo-interop-kb/docs/conversion-conformance-testing - **Category:** quality - **Edge cases:** pentagons, antimeridian, tiny-polygons, narrow-polygons, holes - **Related:** h3-to-inscribed-circle, h3-to-circumscribed-circle, conversion-quality-metrics --- Unit tests written against a handful of convenient cells — a mid-latitude hexagon, a simple square polygon — will pass for years and still hide bugs that only surface on the twelve pentagons per resolution, cells that straddle the antimeridian, or cells near the poles where the icosahedron projection distorts most. Conformance testing in this knowledge base has two layers: machine-readable fixtures that pin exact expected numeric outputs with explicit tolerances, and property-based tests that check invariants which must hold across every input, not just the ones a fixture happens to cover. ## Fixture shape A fixture is a JSON object validated against `ConformanceFixtureSchema`: `id`, an optional `description` and `method` name, an `input` record, a `parameters` record, an `expected` record, a `tolerance` record (per-field absolute tolerances), and free-text `notes`. Fixtures are re-derived on every test run by calling the real library function with `input` and `parameters` and comparing the result to `expected` within `tolerance` — they are not just stored answers, they are executable regression checks against the exact functions documented elsewhere in this KB (`inscribedCircle`, `circumscribedCircle`, `polygonToH3`, and so on). ```json { "id": "h3-inscribed-pentagon-r5", "description": "inscribed circle for cell 85080003fffffff", "method": "h3-to-inscribed-circle", "input": { "cell": "85080003fffffff" }, "parameters": { "edgeSamples": 64 }, "expected": { "center": [64.700000128, 10.536199075], "radiusMeters": 6050.9753, "cellAreaM2": 127785582.61, "isPentagon": true }, "tolerance": { "radiusMeters": 0.05, "cellAreaM2": 1 }, "notes": [ "Radii are spherical (haversine) metres.", "inscribed cell and cell circumscribed hold within SAFETY_MARGIN." ] } ``` The tolerance on `radiusMeters` here (0.05 m on a radius of roughly 6 km) is tight enough to catch a regression in the edge-densification sample count or the safety-margin constant, but loose enough to absorb floating-point differences between test runs and library versions. ## Key properties Property-based tests do not assert one expected number; they assert a relationship that must hold for every cell, polygon, or cell set fed into the function under test. 1. **Every sampled point in an inscribed circle lies inside the source cell, within tolerance.** For a densified sample of the cell boundary (a great-circle-interpolated set of points along every edge, not just the original vertices), no boundary sample may fall strictly inside the inscribed disk — if one does, the disk extends past the true boundary and the subset guarantee is broken. 2. **Every sampled source-cell boundary point lies inside the circumscribed circle, within tolerance.** Every point on the densified boundary must be at or within the circumscribed radius from the cell center; a violation means the circle fails to fully contain the cell it claims to bound. 3. **Fully-contained cell results do not extend outside the source polygon.** For `full`-mode polyfilling, every returned cell's own boundary, not just its center, must lie within the source polygon — a center-contained check is not sufficient evidence for a `full`-mode guarantee, and the test must verify the stronger claim the mode name makes. 4. **Compact then uncompact preserves the normalized set.** `uncompact(compact(cells), resolution)` must return exactly the same set of cells (as a set, not an ordered list) that `uncompact` produced from the original mixed or uniform input at that resolution — compaction is a lossless re-encoding of a cell set, and any input/output mismatch is a correctness bug, not an approximation. 5. **Weighted overlap fractions sum consistently.** For a cell fully partitioned by a set of regions (no gaps, no overlaps in the source regions), the sum of `cellCoverageFraction` across all `(cell, region)` links for a given cell must equal 1 within numerical tolerance — a sum below 1 indicates a missed region overlap, and a sum above 1 indicates double-counted area. ## Normal and pathological fixtures Fixtures are organized to cover both the common case and the failure modes that only appear geometrically: | | | |---|---| | Normal hexagon | A mid-latitude, non-pentagon, non-boundary-crossing cell — the baseline case every function must get right before anything else matters. | | Pentagon | One of the twelve pentagon cells per resolution, which break the six-neighbor and regular-hexagon-ratio assumptions several algorithms silently rely on. | | Antimeridian | A cell whose boundary or center sits at or crosses plus-or-minus 180 degrees longitude, where naive planar longitude arithmetic produces a world-spanning artifact. | | Near-polar | A cell at high latitude where icosahedron face distortion is largest and small-angle approximations in some geometry libraries break down. | | Hole | A polygon with an interior ring, verifying that cells inside the hole are correctly excluded rather than treated as covered. | | Narrow | A polygon thinner than a cell's width at the target resolution, which can legitimately return zero center-contained cells despite having positive area. | | Tiny | A polygon much smaller than a single cell, testing that intersect-mode still returns the enclosing cell rather than an empty set. | Each pathological category maps back to a named edge case elsewhere in this KB — pentagons to [h3-pentagons](/docs/h3-pentagons/), antimeridian cells to [antimeridian-handling](/docs/antimeridian-handling/), and so on — so a failing fixture points directly at the conceptual page explaining why the input is hard, not just at a numeric mismatch. ## Running the checks Fixtures live as one JSON file per case under a conformance directory, indexed by an `index.json` manifest; the test harness reads every fixture, re-derives the result with the real library function named in `method`, and asserts the numeric fields fall within their declared tolerance while boolean fields (such as `isPentagon`) match exactly. Property tests are separate, hand-written test suites that iterate representative cells — typically an ordinary hexagon, a pentagon, a near-polar cell, and an antimeridian cell in the same suite — and assert the relationships in the Key Properties section above hold for every one of them, not just for whichever fixture happens to be checked in. > **Note:** A new geometry conversion function is not conformance-tested until it has at least one fixture per pathological category above, plus a property test for the invariant the function is supposed to guarantee. A function with only normal-case fixtures has not been tested against the inputs most likely to break it. --- # Conversion Profiles > Seven named profiles bundle a containment rule, resolution policy, circle mode, and weighting into a reusable recipe for a stated intent — they are defaults, not universal answers. - **URL:** https://etherdata.ai/blog/geo-interop-kb/docs/conversion-profiles - **Category:** concepts - **Edge cases:** minimum-radius, mixed-resolutions - **Related:** resolution-selection, arbitrary-polygon-to-h3, h3-to-inscribed-circle, privacy-and-minimum-aggregation --- A conversion profile is a named bundle of choices — containment mode, resolution policy, circle mode, weighting, and (where relevant) a privacy rule — that together answer one stated intent. Profiles exist so that "convert this to H3" is never an underspecified request: naming a profile forces every downstream consumer to know, without re-deriving it, what guarantees hold and what tradeoffs were accepted. They are defaults for an intent, not universal truths; every profile below states the case where it is the wrong choice. ## The seven profiles | | | |---|---| | partition_stable | Mutually-exclusive administrative partitions — every location belongs to exactly one region. | | coverage_complete | Delivery/serviceable-area coverage where missing ground is worse than spilling over. | | measurement_weighted | Reporting and analytics that apportion values across many-to-many cell/region relationships. | | experiment_conservative | Geo-experiments that must minimize treatment/control contamination. | | proximity_full_reach | Maximizing reach around a set of cells or points, with overlap accepted and reported. | | platform_limit_optimized | Fitting a target set within a platform's target-count and minimum-radius limits. | | privacy_safe | Aggregation that avoids small-cell re-identification and sparse-audience exposure. | ## Per-profile detail ### partition_stable Containment mode is `center`: a cell belongs to a region if and only if the cell's center falls inside it. Resolution policy is a single uniform resolution across the whole partition. No weighting is applied. This guarantees a deterministic, single assignment per cell with no double counting — the property most rollup and budgeting logic assumes. The tradeoff is boundary undercoverage: cells whose center sits just outside a region are dropped from it, and a region narrower than one cell at the chosen resolution can receive zero cells. Recommended metrics: `coverage_ratio` and `underreach_ratio` per region. ### coverage_complete Containment mode is `intersect`: any cell touching the source polygon with positive intersection area qualifies. Resolution should be fine enough that the resulting overreach is acceptable for the use case. No weighting. This guarantees every point of the source polygon is covered by at least one cell — the property serviceable-area and reach use cases need. The tradeoff is boundary overreach and overlap with neighboring regions, since intersect-mode cells are not mutually exclusive. Recommended metrics: `coverage_ratio`, `overreach_ratio`, `jaccard`. ### measurement_weighted Containment mode is `intersect`, but the output is the full [weighted crosswalk](/docs/h3-to-administrative-crosswalk/) table — every `(cell, region)` overlap retained with its intersection area and coverage fraction — rather than a single assignment. Resolution should match the measurement grain, and the weighted table must be kept, not collapsed to an argmax. Weighting can be area, population, audience, or inventory, applied via the coverage fraction as an apportionment factor. This guarantees a complete many-to-many overlap record; the tradeoff is materially more storage and the need to source and version whatever weight is applied. Recommended metrics: `coverage_ratio`, `jaccard`. ### experiment_conservative Containment mode is `full` (cell fully inside the source polygon) combined with the [inscribed circle](/docs/h3-to-inscribed-circle/) for any point+radius execution; resolution policy favors coarser cells or explicit buffering to create non-touching experimental units. No weighting. This guarantees executed geometry is a subset of the intended unit — no spill into neighboring units, which is the property a treatment/control design depends on. The tradeoff is deliberate underreach: boundary area and cell corners are left unserved by construction. Recommended metrics: `underreach_ratio`, `uncovered_area_m2`. ### proximity_full_reach Circle mode is [circumscribed](/docs/h3-to-circumscribed-circle/); resolution policy is one outer circle per cell in the target set. No weighting. This guarantees cell subset-of circle — complete coverage of every target cell. The tradeoff is that neighboring circles overlap, producing duplicate eligibility that must be reported via `duplicate_eligibility_area_m2`, not hidden. Recommended metrics: `overreach_ratio`, `duplicate_eligibility_area_m2`. ### platform_limit_optimized Containment mode is `intersect`, circle mode is circumscribed where circles are needed; resolution policy is to compact the cell set and coarsen iteratively until the result is under the platform's `maxTargets` cap and above its `minRadius` floor. No weighting. This guarantees the result respects the named platform's target-count and minimum-radius constraints — the property that determines whether a buy can even be submitted. The tradeoff is that coarsening enlarges the effective footprint, trading precision for platform compatibility. Recommended metrics: `overreach_ratio`, `coverage_ratio`. ### privacy_safe Containment mode is `center`; resolution policy enforces both a minimum physical cell size and a minimum audience threshold, degrading resolution and suppressing sparse cells as needed. No weighting. Privacy rule: k-anonymity threshold, sparse-cell suppression, and a minimum aggregation window applied together, not any one alone. This guarantees no cell is reported below the configured audience or area threshold. The tradeoff is resolution degradation and suppression reducing granularity, sometimes substantially in sparse geographies. Recommended metrics: `coverage_ratio`, alongside the suppression count itself (cells dropped, not just cells kept). ## Choosing among them The profiles are not mutually exclusive stages of one pipeline — a single campaign typically needs more than one. A privacy_safe audience definition might feed a proximity_full_reach execution for delivery, measured against a measurement_weighted crosswalk for reporting, with an experiment_conservative subset carved out for a lift study. Naming which profile governs which stage of a pipeline is what keeps the choices auditable; using one resolution and one containment rule for all of them, because it happened to work for the first stage, is the failure mode this page exists to prevent. > **Note:** Every guarantee above holds only under the stated containment mode and resolution policy. A `coverage_complete` cell set has near-zero `underreach_ratio` by construction, but that says nothing about its `overreach_ratio` — high coverage and high overreach are not mutually exclusive, and a profile chosen for one guarantee does not automatically deliver the others. See [conversion quality metrics](/docs/conversion-quality-metrics/) for how to check a profile actually delivered what it promises on a specific geography. --- # Conversion Quality Metrics > Coverage, overreach, underreach, and Jaccard share the same numerator family but different denominators, so a geometry can score high on one and poorly on another simultaneously. - **URL:** https://etherdata.ai/blog/geo-interop-kb/docs/conversion-quality-metrics - **Category:** quality · **Exactness:** exact - **Edge cases:** tiny-polygons, touching-only - **Related:** requested-vs-executed-geography, h3-to-circumscribed-circle --- exact Every conversion in this knowledge base can be scored against its source geometry using a small, fixed set of area-based metrics. All areas below are spherical square meters computed on EPSG:4326 rings (turf's spherical area estimate); all metrics compare exactly two geometries at a time — a **source** (what was requested or normalized) and an **execution** (what will actually run, or did run). ## Base quantities | | | |---|---| | source area | area(source) — the normalized source polygon's area, m2. | | execution area | area(execution) — the executed geometry's area (circle, simplified polygon, cell-set outline), m2. | | intersection | area(source cap execution) — ground both claim, m2. | | union | area(source cup execution) — ground either claims, m2. | | uncovered_area | area(source minus execution) — asked-for ground with no execution coverage, m2. | | duplicate_eligibility_area | Sum of individual feature areas minus the area of their union, for a set of overlapping executed features (e.g. circles) — ground eligible under more than one target, m2. | Population, audience, and inventory covered are derived quantities, not independent metrics: they are computed by applying a per-cell weight (population density, audience count, inventory volume) to the intersection area or the coverage fraction, exactly as the [weighted crosswalk](/docs/h3-to-administrative-crosswalk/) does. They inherit the same denominator caveats as `coverage_ratio` below and should always be reported alongside the ratio, not instead of it. ## The four ratios Each ratio uses `source area` as the denominator except Jaccard, which uses the union. Read the denominator before comparing two ratios across different geometries. $$ \text{coverage\_ratio} = \frac{\text{area}(\text{source} \cap \text{execution})}{\text{area}(\text{source})} $$ $$ \text{overreach\_ratio} = \frac{\text{area}(\text{execution} - \text{source})}{\text{area}(\text{source})} $$ $$ \text{underreach\_ratio} = \frac{\text{area}(\text{source} - \text{execution})}{\text{area}(\text{source})} $$ $$ \text{jaccard} = \frac{\text{area}(\text{source} \cap \text{execution})}{\text{area}(\text{source} \cup \text{execution})} $$ > **Note:** `coverage_ratio` and `overreach_ratio` share a numerator family but are not complementary — they do not sum to 1, and neither bounds the other. Circumscribing every cell in a region produces `coverage_ratio` at or above 0.999 (the source is fully contained in the union of circles, by construction) while `overreach_ratio` is strictly positive and can exceed 1 if the circles are large relative to the source polygon — meaning the executed geometry is larger than the entire source, not merely imperfectly aligned with it. Never report `coverage_ratio` alone as a proxy for targeting precision; always pair it with `overreach_ratio` or `jaccard`. `underreach_ratio` is the complement structure to watch instead: for a single-source, single-execution comparison, `coverage_ratio + underreach_ratio = 1` always holds, because `area(source cap execution) + area(source - execution) = area(source)` by set-algebra identity regardless of what the execution geometry looks like. `overreach_ratio` has no such fixed relationship to the other two because its numerator is measured against execution, not source. ## Boundary displacement, counts, and coverage denominators Boundary displacement is a distance metric, not an area metric: the maximum or mean perpendicular distance between the source boundary and the nearest point on the execution boundary, in meters. It answers "how far did the edge move," which `overreach_ratio` and `underreach_ratio` cannot answer on their own — a geometry can have small `overreach_ratio` and still have a boundary that moved considerably if the source polygon is large relative to the displacement. Counts (cells, targets, regions) are reported alongside area metrics but are not substitutes for them: cell count says nothing about coverage without knowing the resolution, and a small cell count at a coarse resolution can cover more area than a large cell count at a fine resolution. "Population/audience/inventory covered" figures must always be reported with the coverage_ratio and underreach_ratio that produced them, since a weighted total with no denominator context cannot be checked against the source ask. ## Algorithm ```ts // Coverage/overreach/underreach/jaccard for one cell approximated by its // circumscribed circle. const cell = "872830829ffffff"; const sourcePoly = cellToPolygon(cell); const circle = circumscribedCircle(cell); const executionPoly = turf.circle( [circle.center[1], circle.center[0]], circle.radiusMeters / 1000, { units: "kilometers", steps: 128 }, ); const m = coverageMetrics(sourcePoly, executionPoly); // m.coverageRatio ~ 1 (circumscribed circle fully contains the cell) // m.overreachRatio > 0 (the disk covers ground outside the hexagon) // m.jaccardSimilarity < 1 (disk area exceeds cell area) // Duplicate eligibility across two adjacent cells' circumscribed circles. const neighborCircle = circumscribedCircle("872830828ffffff"); const neighborPoly = turf.circle( [neighborCircle.center[1], neighborCircle.center[0]], neighborCircle.radiusMeters / 1000, { units: "kilometers", steps: 128 }, ); const dupArea = duplicateEligibilityAreaM2([executionPoly, neighborPoly]); // dupArea > 0: ground eligible under both circles. ``` The same conversion with the Python bindings (`h3-py` v4): ```python from shapely.geometry import Polygon def cell_to_polygon(cell: str) -> Polygon: boundary = h3.cell_to_boundary(cell) return Polygon([(lng, lat) for lat, lng in boundary]) def coverage_metrics(source: Polygon, execution: Polygon) -> dict: intersection = source.intersection(execution).area union = source.union(execution).area return { "coverage_ratio": intersection / source.area, "overreach_ratio": execution.difference(source).area / source.area, "underreach_ratio": source.difference(execution).area / source.area, "jaccard": intersection / union, } cell = "872830829ffffff" source_poly = cell_to_polygon(cell) # Executed geometry approximated by a circumscribed circle around the cell # center — build it the same way the TS lib's circumscribedCircle does # (great-circle radius, densified boundary), not with a planar buffer. execution_poly = circumscribed_circle_polygon(cell) m = coverage_metrics(source_poly, execution_poly) # m["coverage_ratio"] ~ 1 (circle fully contains the hexagon) # m["overreach_ratio"] > 0 (the disk covers ground outside the hexagon) # m["jaccard"] < 1 (disk area exceeds cell area) # Cell-count-free area: h3.cell_area never requires counting cells to size # a region, unlike a count-times-nominal-area estimate. exact_cell_area_m2 = h3.cell_area(cell, unit="m^2") ``` The tested reference implementation in this knowledge base is the TypeScript in `lib/`; it computes every area above as spherical (haversine-consistent) m², whereas `shapely`'s `.area` on raw lat/lng coordinates is planar and only adequate for a rough illustration at this scale. > Figure: full: coverage 49.4%, overreach 0% > Figure: intersect: coverage 100%, overreach 68% ## Reading the outputs together A conversion report should never publish a single ratio in isolation. `coverage_ratio` alone cannot distinguish a tightly-fit execution from a grossly oversized one that happens to fully contain the source; pairing it with `overreach_ratio` (or `jaccard`, which penalizes both under- and over-coverage in one number) closes that gap. `duplicate_eligibility_area_m2` is the only metric here that requires more than two geometries — it is defined over a set of executed features, and is the correct diagnostic for "how much ground is double-counted," which neither `overreach_ratio` nor `jaccard` computed pairwise can reveal, since overlaps between two non-source features never appear in a source-vs-single-execution comparison. ## Edge cases Tiny polygons ([tiny-polygons](/docs/geometry-catalogue/)) produce unstable ratios when the source area approaches the numerical noise floor of the area calculation — a source polygon a few square meters in extent can show `overreach_ratio` in the hundreds or thousands purely because the denominator is small, not because the execution is unusually bad; treat extreme ratios on tiny sources as a signal to inspect absolute areas, not as a literal severity score. Touching-only intersections ([touching-only](/docs/geometry-catalogue/)) — where a boundary-adjacent cell shares only an edge or point with the source, contributing near-zero intersection area — should be filtered by an intersection-area epsilon before computing `coverage_ratio`, or a geometrically-touching but practically-irrelevant cell will be counted as "covering" the source. ## Illustration — duplicate eligibility > Figure (duplicate-eligibility): Two adjacent cells executed as circumscribed circles: the pink lens is ground eligible under both targets — the duplicate-eligibility area. --- # Coordinate And CRS Failures > Nine recurring data-quality failures in supplied coordinates and boundaries, each with a concrete detection test and mitigation, that must be cleared before any geometry enters the conversion pipeline. - **URL:** https://etherdata.ai/blog/geo-interop-kb/docs/coordinate-and-crs-failures - **Category:** systems · **Exactness:** exact - **Source geometry:** address, device_ping, census_geo, admin_county, postal_code - **Destination geometry:** bounding_box - **Edge cases:** axis-order-reversal, rounded-coordinates, duplicated-region-ids, stale-boundaries - **Related:** geometry-normalization, point-to-h3 --- exact ## Purpose Every conversion in this knowledge base assumes its input is valid EPSG:4326 geometry with a correct, current boundary vintage. That assumption fails routinely, in specific and detectable ways. This page is the checklist run before normalization: nine failure modes, each with a detection test cheap enough to run on every incoming record and a mitigation that does not silently guess at the correct value. ## Wrong or missing CRS A geometry supplied without an explicit CRS is frequently assumed to be EPSG:4326 by convention, but shapefiles and some GIS exports default to a state-plane or UTM projected CRS instead. **Detection**: coordinate magnitudes outside [−180, 180] for longitude or [−90, 90] for latitude immediately rule out unprojected geodetic degrees — a value of 487213.6 is a projected easting, not a longitude. **Mitigation**: require an explicit CRS tag on ingest and reject records without one rather than defaulting to EPSG:4326; if a CRS is declared but suspect, range-check before trusting it, and reproject explicitly rather than relying on downstream code to "figure it out." ## Axis-order reversal (lat/lng swap) GeoJSON specifies `[longitude, latitude]`; many non-GeoJSON sources (some CSV exports, legacy APIs) use `[latitude, longitude]`. A swapped point in the continental US (actual `[-97.5, 35.2]` stored as `[35.2, -97.5]`) produces a coordinate that is still in valid range but lands in the wrong hemisphere. **Detection**: a coordinate out of range in its declared order but valid when swapped is diagnostic; more generally, cross-check a sample against a known reference (does the `[lat, lng]` reading place the record's stated city inside the right country's bounding box?). **Mitigation**: range-check both interpretations and swap when only one is valid; when both orders produce a plausible point (common near the equator and prime meridian, where the ranges overlap), reject rather than guess — a silent swap in the ambiguous zone is worse than a flagged gap. ## Stale boundaries Administrative, postal, and DMA boundaries are revised periodically (annual DMA realignments, postal code splits, county adjustments after annexation). Using an old vintage assigns points and cells to a boundary that no longer matches the authority's current definition. **Detection**: compare the boundary dataset's effective date against the activity period being analyzed; any boundary older than the most recent known revision is suspect. **Mitigation**: version every boundary explicitly with `validFrom`/`validTo`, and record which vintage was used in the `ConversionRecord` for every crosswalk — never treat "the boundary file we have" as "the boundary in effect on the date in question." ## Duplicated region IDs A data-quality error where the same administrative or postal ID is attached to two or more disjoint polygon features — most often from a bad join or an un-deduplicated append of two vintages. This breaks every partition assumption downstream: a max-overlap or weighted crosswalk keyed on that ID silently sums or selects across features that are not the same region. **Detection**: group features by ID and flag any ID mapping to more than one geometry that is not a legitimate multipolygon. **Mitigation**: dedupe or union by ID at ingest, and fail loudly — reject the batch — rather than silently picking one of the duplicates. ## Rounded or truncated coordinates Bidstream and some third-party feeds round coordinates to 2–3 decimal places to reduce payload size or as a privacy measure, snapping a point to a grid far coarser than it implies (2 decimals is roughly 1.1 km at the equator; 3 decimals is roughly 111 m). **Detection**: check the number of significant decimal digits present; fewer than 4 (roughly 11 m or coarser) is suspect for any point-level conversion. **Mitigation**: cap the effective H3 resolution to one consistent with the coordinate's actual precision — assigning a 2-decimal point to an R9 cell (roughly 0.1 km²) implies false precision; treat it as "somewhere within this larger cell," not an exact location. ## Incomplete geometry A polygon record with an open ring, a missing final closing vertex, or a truncated coordinate array (a common symptom of a failed export or a size-limited API response). **Detection**: check ring closure (first vertex equals last vertex) and a minimum vertex count (a ring needs at least 4 positions: 3 distinct vertices plus the closing repeat). **Mitigation**: close open rings only when the gap is a single missing repeat of the first vertex; reject rings with fewer than the minimum or with a gap large enough that closing it would materially change the shape, rather than auto-closing across an arbitrary gap. ## Geocoding uncertainty An address geocoded to a point carries an accuracy tier (rooftop, parcel centroid, street interpolation, city centroid) that is frequently dropped by the time the point reaches a conversion pipeline, leaving a point that looks rooftop-precise but is actually a city centroid. **Detection**: require the geocoder's accuracy tier to travel with the point as metadata; its absence on a geocoded, non-GPS point is itself the signal. **Mitigation**: cap the effective resolution used for downstream cell assignment to match the disclosed accuracy tier, as with rounded coordinates above. ## Zero-island coordinates A point at exactly `(0, 0)` — the equator/prime-meridian intersection, in open ocean off West Africa — is the default, unset value for many numeric coordinate fields (an uninitialized float pair, a failed geocode with nulls coerced to zero, a parsing error). It is a valid coordinate but is disproportionately never a real observation. **Detection**: flag any record at `(0, 0)` to within floating-point tolerance for review rather than accepting it as a legitimate ping. **Mitigation**: treat as a missing-value sentinel by default; accept as real only with explicit corroborating evidence. ## Assumptions and limitations All range checks in this page use the standard geodetic bounds: longitude in [−180, 180], latitude in [−90, 90]. These checks catch out-of-range and swap-detectable errors; they do not catch a coordinate that is in-range, correctly ordered, and still simply wrong (a correct-looking point placed at the wrong address) — that class of error requires corroboration against an independent source, which is outside the scope of coordinate-level validation. --- # Geographic Interoperability Model > The six geographies that a single campaign passes through, why they must stay distinct, and why provenance has to survive every conversion. - **URL:** https://etherdata.ai/blog/geo-interop-kb/docs/geographic-interoperability-model - **Category:** concepts - **Edge cases:** stale-boundaries - **Related:** requested-vs-executed-geography, geometry-normalization, conversion-quality-metrics --- A geographic target looks like one object — "the New York market" — but it is handled as a chain of **distinct** objects, each a lossy or lossless transform of the last. Conflating them is the root cause of most "the numbers don't match" disputes between buying, execution, and measurement. ## The canonical pipeline ```mermaid flowchart TB R["Requested geography
(what the buyer asked for)"] --> S["Source geography
(what was supplied)"] S --> N["Normalized geometry
(EPSG:4326, repaired)"] N --> H["Canonical H3
(resolution + containment rule)"] H --> X["Executed geography
(circles / polygons / IDs)"] X --> P["Reported geography"] P --> A["Attributed geography"] ``` Every arrow is a conversion documented elsewhere in this knowledge base, and every arrow can change the geography. The model exists so that each object has a name, a schema, and a provenance record — and so a claim like "we targeted the polygon" can be checked against what was actually executed. ## The six geographies | | | |---|---| | Requested | The buyer's ask in their own vocabulary: a DMA id, a 3-mile radius, a named trade area. Often not a geometry at all — an identifier. | | Source | The geometry actually supplied to represent the ask: a shapefile, a GeoJSON polygon, a point list. May already differ from the request. | | Normalized | Source geometry validated and reprojected to EPSG:4326 GeoJSON: closed rings, correct winding, antimeridian split, holes respected. | | Canonical H3 | A set of H3 cells at a stated resolution under a stated containment rule. The interchange form all downstream conversions start from. | | Executed | What a platform can actually run: point+radius circles, simplified polygons, or native geo IDs — an approximation of the H3 set. | | Reported / attributed | The geography used for delivery reporting and outcome attribution — frequently coarser than what was executed. | > **Note:** An H3 cell executed as a circumscribed circle covers ground the cell does not. A DMA polyfilled to H3 and then mapped back to postal codes is not the same set of households you started with. If a report says "postal code" but execution ran on circles, the attribution geography and the executed geography disagree — and that gap is measurable, not rhetorical. ## Why provenance must survive conversion Each hop should append to a `ConversionRecord`, never overwrite it. The minimum that must be recoverable at the end of the chain: - the **source** CRS, vendor, and boundary vintage; - the **normalization** actions taken (what was repaired); - the H3 **resolution** and **containment mode**; - the **approximation mode** used for execution (inner/outer/equal-area circle, polygon simplification tolerance, crosswalk vintage); - which **exclusions or targets were dropped** because a platform could not express them. Without this, you cannot answer the questions this knowledge base is organized around: *what was requested, what was executed, and why do they differ?* ## A worked gap Suppose the request is a county (a partition unit), executed on a DSP that only accepts point+radius circles. $$ \text{county polygon} \xrightarrow{\text{polyfill, R7, intersect}} \{h_1 \dots h_n\} \xrightarrow{\text{circumscribed}} \{(c_i, r_i)\} $$ The intersect polyfill already overreaches at the county boundary; the circumscribed circles overreach again and overlap each other. The executed footprint is strictly larger than the requested county, and some ground is eligible under two circles at once. None of that is wrong — but it must be **reported**, via the [quality metrics](/docs/conversion-quality-metrics/), not hidden behind the phrase "we targeted the county." This page has no `ts` algorithm block of its own — the pipeline it narrates is coded page by page elsewhere in this knowledge base. As a short illustration, the same Requested → Canonical H3 → Executed steps with the Python bindings (`h3-py` v4): ```python # Requested -> Source -> Normalized: a county ring, already reprojected to # EPSG:4326 as (lat, lng) pairs. county_ring = [ (40.70, -74.02), (40.70, -73.98), (40.74, -73.98), (40.74, -74.02), (40.70, -74.02), ] county_shape = h3.LatLngPoly(county_ring) # Normalized -> Canonical H3, at resolution 7. # NOTE: h3-py's polygon_to_cells uses CENTER containment, like h3-js — the # "intersect" rule this worked example uses is not a one-liner; it means # classifying each candidate cell yourself (shapely intersection area # against the source ring), exactly as the TS lib's polygonToH3 does. canonical_cells = h3.polygon_to_cells(county_shape, res=7) # Canonical H3 -> Executed: dissolve the cell set back into a polygon # footprint (here, a stand-in for the circumscribed-circle executed # geometry the worked example above actually uses). executed_shape = h3.cells_to_h3shape(canonical_cells, tight=True) ``` The tested reference implementation in this knowledge base is the TypeScript in `lib/`, which implements the `intersect` and `full` containment rules explicitly rather than relying on `h3-py`'s center-containment default. > Figure (polygon-intersect): A requested square vs its executed H3 intersect fill: 100% coverage but 68% overreach — requested is not executed. > **Note:** Rule of thumb: never say "converted to H3" or "same geography" without also stating the resolution, the containment rule, the approximation mode, and the units. Every page here that describes a conversion is required to state all four. --- # Geohash Overview > Geohash as a system: base-32 prefix strings over a recursively bisected lat/lng rectangle, exact prefix containment, 1-12 character lengths, non-equal-area cells, and no native polygon fill. - **URL:** https://etherdata.ai/blog/geo-interop-kb/docs/geohash-overview - **Category:** systems · **Exactness:** approximate - **Source geometry:** h3_cell_set - **Destination geometry:** h3_cell_set - **Edge cases:** antimeridian, mixed-resolutions - **Related:** cell-system-comparison, h3-overview --- approximate ## What Geohash is Geohash is a discrete global grid system that encodes a lat/lng rectangle as a base-32 string. It works directly in unprojected latitude/longitude degrees — no icosahedron, cube, or dodecahedron projection is involved the way there is for [H3](/docs/h3-overview/) or [S2](/docs/s2-overview/) — which is both its simplicity and the source of its most severe geometric limitation (non-equal-area cells that shrink sharply toward the poles, described below). Geohash is documented here on the same generic terms as the other systems; see [cell-system-comparison](/docs/cell-system-comparison/) for the full matrix. ## Construction: recursive bisection, base-32 encoding A geohash is built by recursively bisecting a starting bounding box (the whole lat/lng extent) in half, alternating which axis is bisected — longitude first, then latitude, then longitude again — and appending a bit for which half the target point falls in (0 for the lower half, 1 for the upper half). Every 5 bits accumulated this way are encoded as one base-32 character. Because each character encodes 5 bits and the axis alternates every bit (not every character), each additional character subdivides the current rectangle into 32 pieces arranged as an 8-wide by 4-tall grid where longitude got the extra bit, or 4-wide by 8-tall where latitude did — the two patterns alternate by string position. The net effect: each character subdivides the parent rectangle into 32 children, none of which is a hexagon, spherical quadrilateral, or pentagon — simply a smaller lat/lng rectangle in degrees. ## Hierarchy: exact prefix containment Geohash's hierarchy relationship is a string-prefix relationship, and it is **exact**: every point inside a geohash of length N falls inside every shorter geohash that is a prefix of it, with no exception, because the subdivision is a literal recursive bisection of the bounding rectangle — no approximation or index-arithmetic step analogous to H3's aperture-7 logic. Geohash's containment guarantee is as strong as S2's exact quad tiling, though the two arrive at exact containment by different constructions (recursive bisection vs. an exact quad tree of spherical quadrilaterals), and Geohash's hierarchy factor is 32 children per parent rather than 4 (S2) or approximately 7 (H3). ## Lengths and non-equal-area cells Geohash strings in common use range from 1 to 12 characters. Because the subdivision is performed directly in latitude/longitude degrees rather than on a projected or equal-area surface, a geohash rectangle's true ground area at a fixed string length is **not** constant: a degree of longitude covers less ground distance as latitude increases toward the poles (proportional to the cosine of latitude), so geohash cells of the same length shrink toward the poles and are widest at the equator — a more severe, more geometrically obvious area variance than H3's (roughly 2x, icosahedron projection) or S2's (cube projection). A geohash near a pole can be a small fraction of the ground area of an equatorial geohash of the same length, with no correction applied by the encoding itself. > **Note:** Because bisection is purely coordinate-based, two points that are geographically adjacent but fall on opposite sides of a bisection boundary — most sharply at the equator, the prime meridian, or the antimeridian — can produce geohash strings that share no meaningful prefix at all, even though the points are close together. A naive proximity search using string-prefix similarity will silently miss nearby points across such a boundary; see [antimeridian-handling](/docs/antimeridian-handling/) for the general edge/discontinuity problem this is one instance of. ## Core operations | | | |---|---| | Point indexing | Encoding a lat/lng point to a geohash string of a given length is the direct output of the recursive-bisection encoding process itself — there is no separate lookup step the way H3's `latLngToCell` walks an icosahedron hierarchy. | | Boundary extraction | A geohash string decodes directly to its bounding rectangle (min/max lat, min/max lng) — the Geohash equivalent of `cellToBoundary`, and exact by construction since the rectangle *is* the encoding. | | Centroid extraction | The rectangle's midpoint is the conventional decoded 'center' of a geohash, analogous to `cellToLatLng`. | | Polygon fill | Geohash has no native polygon-fill primitive analogous to H3's `polygonToCells` or S2's `S2RegionCoverer`. Coverage is approximated by enumerating candidate prefixes over a polygon's bounding box and testing each candidate rectangle for intersection with the target polygon, discarding non-intersecting prefixes — an approximate, hand-rolled equivalent, not a built-in system capability. | | Compaction | Geohash has no built-in compaction operation comparable to H3's `compactCells` or S2's cell-ID range collapsing. A caller wanting to collapse 32 sibling prefixes into their common parent prefix must implement that check manually against the full sibling set. | ## Use cases: prefix bucketing and key-range scans Geohash's practical advantage is operational simplicity: a geohash string sorts and range-scans naturally in any ordinary string-keyed index (a database B-tree, a key-value store, a URL path segment), and truncating a string to a shorter prefix is itself the exact coarsening operation — no separate "get parent cell" call is needed. This makes Geohash reasonable for simple proximity bucketing or key-range scans where polar area distortion and the lack of native polygon fill are acceptable trade-offs, but a poor choice wherever precise polygon coverage or robust near-boundary proximity matching is required — those are better served by [H3](/docs/h3-overview/) or [S2](/docs/s2-overview/). ## What must not be assumed Do not assume Geohash cells are equal-area — the polar shrinkage is severe and unlike either H3's or S2's projection-driven variance. Do not assume Geohash supports native polygon fill or compaction — both must be hand-built from the prefix/rectangle primitives. Do assume prefix containment is exact, and do assume string proximity is *not* a reliable proxy for spatial proximity near bisection boundaries. ## References - Geohash — [Wikipedia: Geohash](https://en.wikipedia.org/wiki/Geohash) (last verified 2026-07-22) ## Assumptions and limitations This page describes Geohash's generic capability model as registered in `data/cell-systems.yaml`. Specific base-32 alphabet choice, exact bit-per-character packing, and any platform-specific length convention should be verified against the geohash implementation in use before being relied on for a production calculation. ## Illustration — a real geohash cell > Figure (cell-geohash): An actual 7-character geohash cell (ngeohash): a lat/lng rectangle whose aspect ratio alternates with length and shrinks toward the poles. --- # Geometry Catalogue > An index of every geometry type this knowledge base converts, grouped by category, with the metadata each needs and the single rule that an identifier is never itself a geometry. - **URL:** https://etherdata.ai/blog/geo-interop-kb/docs/geometry-catalogue - **Category:** geometries - **Edge cases:** stale-boundaries, holes, multipart-geometries, tiny-polygons, gps-noise - **Related:** geographic-interoperability-model, point-to-h3, arbitrary-polygon-to-h3, h3-to-platform-native-geography --- This page indexes every geometry type this knowledge base converts to or from H3, grouped by category. Each entry links to the dedicated conversion pages covering it in full; this page's job is to say, per group, what the geometry fundamentally is, what metadata it needs before conversion, and what goes wrong most often. > **Note:** A FIPS code, a DMA id, a postal code — none of these is a geometry. Each is a reference into a versioned boundary set, and the boundary it resolves to depends on which vintage of that set is in force. `"36061"` is not a shape; `"36061" + TIGER 2024 vintage` is. Treating an identifier as if it carries its own geometry is the most common source of silent misassignment in this catalogue, because the identifier string never changes even when the boundary it refers to does — see [stale boundaries](/docs/requested-vs-executed-geography/). ## Administrative and statistical boundaries Countries, states/provinces, counties, census geographies, DMAs, and postal areas. These are consumed as **partitions**: downstream reporting, budgeting, and compliance logic typically assumes every location belongs to exactly one region. Required metadata: boundary source, vintage, CRS, and a namespace for the id (FIPS, GEOID, ISO code, DMA id, ZIP/ZCTA). Common risks: boundary vintage drift (DMA and postal boundaries are redrawn periodically), leading zeros dropped from FIPS/GEOID strings by an integer cast, and postal codes specifically — a ZIP code is a USPS delivery-route abstraction with no authoritative polygon; any "ZIP polygon" in circulation is a third party's ZCTA approximation and must be labeled as such. See [administrative polygon to H3](/docs/administrative-polygon-to-h3/). ## Arbitrary polygons Trade areas, geofences, and parcels: operator-drawn or model-generated shapes with no external authority governing their boundary. Required metadata is the generation method (drive-time model, gravity-model catchment, manual digitization), a vintage timestamp, and CRS. Common risks: self-intersection (bowtie rings from manual digitization or model artifacts), narrow slivers and tiny polygons that can legitimately yield zero center-contained cells, and holes that must be respected rather than filled through. See [arbitrary polygon to H3](/docs/arbitrary-polygon-to-h3/). ## Points POI/store points, addresses, and device pings: a single coordinate that references a place rather than describing an extent. Required metadata is CRS, source, and a timestamp; device pings additionally need an accuracy radius and consent state, and addresses need the geocoder used and its match confidence. Common risks: geocoding uncertainty (rooftop vs. centroid placement), axis-order reversal, and — for device pings — IP-derived locations that are coarse and centroid-biased and must be labeled with the correct matching semantic rather than treated as physical presence. See [point to H3](/docs/point-to-h3/). ## Point + radius A center coordinate with a geodesic radius — the native execution unit for many DSPs and the inverse of an H3 circle approximation. Required metadata is the radius units and whether the radius is geodesic or planar, since a planar radius diverges from a geodesic one as it grows or as latitude increases. Common risks: platform minimum-radius floors that reject small radii outright, and radius increments that round a requested radius up or down, silently changing coverage. See [point-radius to H3](/docs/point-radius-to-h3/). ## Lines and trajectories Road/transit lines and device trajectories: an unordered polyline versus an ordered, timestamped sequence of positions. Lines need CRS and direction; trajectories additionally need timestamps, sampling rate, and consent state, since an ordered high-resolution trajectory can be re-identifying even when individual points are not. Common risks: GPS noise causing boundary oscillation (a path skimming a cell edge flips back and forth between cells), and, for trajectories, exposure risk distinct from a single point's. See [line and corridor to H3](/docs/line-and-corridor-to-h3/). ## Multipoints Bid requests, visits, conversions, sensor events: a collection of independent point observations rather than one coherent shape. Required metadata is CRS, per-observation timestamp, and a deduplication key. The central risk is duplicate observations — the same impression or visit counted more than once inflates audience or volume per cell — compounded by sparse-audience suppression requirements once the set is aggregated to cells. ## Rasters Gridded fields: population, elevation, weather, pollution, or signal-strength surfaces. Required metadata is CRS, native pixel resolution, the declared no-data sentinel, and band semantics. Common risks: unmasked no-data pixels corrupting an aggregate, a resolution mismatch between pixel size and target cell size producing false precision, and coastal or other mixed pixels straddling two categories without a clean per-cell label. See [raster to H3](/docs/raster-to-h3/). ## Bounding boxes A `[west, south, east, north]` envelope — a map viewport or query bound, not a shape describing any real-world extent. Required metadata is CRS and whether the box is a screen/viewport rectangle or a geographic envelope, since the two are easy to conflate. Common risks: a box crossing the antimeridian where west is numerically greater than east (a naive box-to-polygon conversion produces an inverted or world-spanning result), and rotated boxes that are not axis-aligned. See [bounding box to H3](/docs/bounding-box-to-h3/). ## Platform identifiers Opaque or standardized identifiers — a postal id, a FIPS code, an ISO code, a DMA id, a publisher's own market label. This is the category the warning at the top of this page is about most directly: a platform identifier is never a geometry in its own right. Required metadata is the id namespace, the boundary vintage it was minted against, and the crosswalk source used to resolve it. Common risks: namespace ambiguity (two vendors' "market 12" mean different things), vintage mismatches between assignment and resolution time, and unmatched ids that silently drop out of a join instead of raising an error. See [H3 to platform-native geography](/docs/h3-to-platform-native-geography/). ## Cell sets A set of H3 indices, possibly mixed-resolution and possibly compacted — the canonical interchange form this knowledge base converts everything else into and out of. Required metadata is the resolution (or confirmation that the set is mixed-resolution), whether it has been compacted, and the containment mode used to produce it. Common risks: parent-and-child cells in the same set double-counting shared area, and combining sets at different resolutions without normalizing first. See [H3 compaction and uncompaction](/docs/h3-compaction-and-uncompaction/) and [mixed H3 resolutions](/docs/mixed-h3-resolutions/). ## Using this catalogue Every conversion page here names its source and destination geometry types using the ids implicit in the groups above. When a new geometry type does not obviously fit one of these nine groups, ask which group's required-metadata list it actually satisfies — not which group its name resembles. A "trade area" delivered as a fixed-radius circle around a store point is a point+radius geometry, not an arbitrary polygon, regardless of what the source system calls it, and converting it as a polygon would silently drop the radius-units question the point+radius group forces. --- # Geometry Normalization > Every downstream H3 conversion assumes closed, correctly wound, EPSG 4326 rings with no self-intersections, and normalization is the single gate that must guarantee it. - **URL:** https://etherdata.ai/blog/geo-interop-kb/docs/geometry-normalization - **Category:** concepts · **Exactness:** exact - **Edge cases:** self-intersections, holes, multipart-geometries, axis-order-reversal - **Related:** coordinate-and-crs-failures, antimeridian-handling --- exact Polyfilling, circle construction, and crosswalking all assume their input is a valid GeoJSON polygon in EPSG:4326 with closed, correctly wound rings and no self-intersections. None of those assumptions hold for raw vendor data by default. Normalization is the step that either makes them hold or reports, by code, exactly why it cannot — it is deliberately conservative: it repairs what is safe to repair and flags the rest rather than guessing. ## What normalization covers **CRS detection and reprojection.** Vendor shapefiles frequently arrive in a projected CRS (state plane, Albers equal-area, UTM zones) rather than geographic coordinates. Every polygon must be reprojected to EPSG:4326 (WGS84 longitude/latitude in decimal degrees) before any H3 operation, since h3-js and the polyfill/circle libraries in this KB assume geographic coordinates on a sphere. Reprojection error for CONUS-scale admin polygons is typically sub-meter, but should be verified rather than assumed for Alaska, Hawaii, and territories where projection choice matters more. **Axis-order validation.** GeoJSON specifies `[longitude, latitude]`; many GIS tools, and essentially all human-readable lat/lng pairs, use the reverse order. A coordinate out of range in one order but valid in the other (for example `(74.006, -40.7128)`: 74.006 is out of the minus-90-to-90 latitude range, but valid as `(lng, lat)` reversed) is flagged as a suspected axis swap rather than silently accepted, because guessing wrong places the geometry in a different hemisphere. **Ring closure.** A linear ring's first and last coordinate must be identical; open rings are closed by appending the first vertex. **Winding order.** RFC 7946 requires the exterior ring to wind counter-clockwise and interior (hole) rings to wind clockwise, both as seen from above the plane (right-hand rule on the sphere). Reversed winding does not always break area calculations, but it breaks assumptions some polygon-clipping and point-in-polygon implementations make about which side is "inside," so it is corrected unconditionally. **Self-intersection.** Bowtie or otherwise self-crossing rings make area and containment undefined — a "polygon" that crosses itself does not have a well-defined inside. This is detected (via intersection-point search) and flagged as an error-severity issue; it is not auto-repaired, because the repair (commonly a zero-width buffer) can silently change which parts of the shape are considered interior, and that decision belongs upstream, with whoever owns the source file. **Duplicate vertex removal.** Consecutive identical vertices are collapsed; they add no information and can degenerate downstream triangulation or edge-densification logic. **Multipolygon and hole handling.** Every ring of every part must be passed through independently — holes retain their reversed winding and are excluded from containment, and every disjoint part (offshore islands, exclaves) must be normalized and carried forward; dropping a part because only the first ring was iterated is a common but silent failure. **Invalid coordinate rejection.** Coordinates outside `[-180, 180]` longitude or `[-90, 90]` latitude that are not explained by an axis swap are rejected outright — a corrupted normalization result is worse than an explicit failure. **Antimeridian splitting, polar handling, simplification, precision, and temporal versioning** are each significant enough to warrant their own treatment: antimeridian-crossing rings must be split rather than treated as planar (see [antimeridian handling](/docs/antimeridian-handling/)); polar geometry breaks the small-angle assumptions some simplification algorithms rely on; simplification tolerance must be recorded because it moves which H3 cells later qualify; coordinate precision below the target H3 resolution's edge length is false precision and should be capped, not trusted; and every normalized geometry should be stamped with the vintage of the source file it came from, because boundaries change over time even when the file format does not. ## Validation checklist 1. Confirm or detect source CRS; reproject to EPSG:4326 if not already. 2. Range-check every coordinate against `[-180,180] x [-90,90]`; flag suspected axis swaps (out of range, valid when reversed) as errors. 3. Close every ring (first vertex equals last). 4. Remove consecutive duplicate vertices. 5. Enforce RFC 7946 winding: exterior counter-clockwise, holes clockwise. 6. Detect self-intersections; flag as an error, do not auto-repair. 7. Verify every ring has at least 3 distinct vertices before closure. 8. Iterate all rings of all parts for multipolygons; do not assume a single outer ring. 9. Split any ring whose longitude span exceeds 180 degrees at the antimeridian rather than passing it through as planar. 10. Record simplification tolerance and boundary vintage on the normalized output, not just on the source file. ## Algorithm ```ts const result = normalizePolygon(sourceRings); if (!result.ok) { // result.issues contains only error-severity codes here, e.g. // "self_intersection", "suspected_axis_swap", "out_of_range_coord", // "too_few_vertices" — normalization refuses to guess past these. throw new Error( result.issues.map((i) => `${i.code}: ${i.detail}`).join("; "), ); } // result.normalized is a closed, correctly wound EPSG:4326 GeoJSON polygon. // result.issues may still contain warning-severity entries (e.g. // "wrong_winding", "unclosed_ring", "duplicate_vertex") describing what // was silently repaired — log them, don't discard them. for (const issue of result.issues) { console.warn(`${issue.severity} ${issue.code}: ${issue.detail}`); } ``` ## Edge cases Self-intersecting rings ([self-intersections](/docs/geometry-catalogue/)) are detected via kink search and rejected rather than repaired in place. Holes must be verified for correct (clockwise) winding after any repair step, since a hole with exterior-style winding will be read as additional covered area instead of an exclusion. Multipart geometries ([multipart-geometries](/docs/geometry-catalogue/)) require iterating every ring of every part — a normalizer that assumes one outer ring will silently drop islands and exclaves. Axis-order reversal ([axis-order-reversal](/docs/coordinate-and-crs-failures/)) is only detectable, not always correctable: a coordinate that is in range in both orderings (for example, anything within about 90 degrees of the equator on both axes) cannot be disambiguated from range alone and needs a source-metadata check or a bounding-box sanity test against the expected region. ## Assumptions and limitations Normalization assumes the source rings represent a single coherent geometry at one CRS; it does not attempt to merge, dissolve, or reconcile geometries from multiple sources. It also does not perform reprojection from arbitrary projected CRSes in the browser bundle — it detects the symptom (coordinates outside the valid geographic-degree range with no valid axis-swap explanation) and requires an upstream reprojection step for non-4326 sources rather than guessing a projection to invert. ## Illustration — holes are respected > Figure (polygon-hole): A square with an interior ring: normalization keeps the hole, so center-fill excludes the cells inside it. --- # Google-Style Point-Radius Execution > Executing an H3 cell set on a platform that only accepts many independent point-and-radius targets, one circle per cell - **URL:** https://etherdata.ai/blog/geo-interop-kb/docs/google-style-point-radius-execution - **Category:** h3-to-execution · **Exactness:** approximate - **Source geometry:** h3_cell_set - **Destination geometry:** point_radius - **Edge cases:** minimum-radius, radius-increments - **Related:** h3-to-circumscribed-circle, platform-target-count-constraints, requested-vs-executed-geography --- approximate platform-dependent ## Purpose Google Ads, most DSPs, and most mobile geofencing SDKs share a common execution primitive: a location target is a **circle** — a point plus a radius, subject to a platform-specific minimum radius, a rounding increment, and a cap on how many targets a single campaign or ad group may carry. Executing an H3 cell set on such a platform is not one conversion but a repeated application of a circle choice — one circle per cell — subject to those platform constraints. This page is the execution-layer counterpart to the single-cell circle pages; it covers what changes when "one circle" becomes "many circles under a budget." ## Source geometry and destination geometry Source is an `h3_cell_set` at a stated resolution. Destination is a list of `point_radius` targets, one per cell (in the naive case) or fewer, after optimization (see below) — each entry a center, a radius, and whatever platform-native target id the upload API returns. ## Exactness class **Approximate**, and for two independent reasons layered on top of each other: first, whichever single-cell circle is chosen (inner, outer, or equal-area) already carries that construction's own gap or overlap behavior; second, the platform's own radius rounding and minimum-radius floor perturb the chosen radius again after it is computed. Never describe this as "targeting the H3 cells" without naming both the circle mode and the platform's rounding behavior. ## Containment rule and boundary behavior | | | |---|---| | Inner (inscribed) per cell | One circle per cell, no cross-cell overlap, systematic gaps at every cell's corners. Use when double-counted reach or double-billed impressions across adjacent cells is the primary risk to avoid. | | Outer (circumscribed) per cell | One circle per cell, guaranteed per-cell coverage, systematic overlap with every neighboring cell's circle. Use when under-delivery to any part of a targeted cell is the primary risk to avoid. | | Equal-area per cell | One circle per cell, area-matched but neither contained by nor containing its cell; gaps and overlaps both present. Use only for reach/budget planning math upstream of the actual buy, never as the executed target list itself. | > Figure: Circumscribed circles overlap at seams → duplicate eligibility > Figure: Inscribed circles leave corner gaps → no overlap Whichever mode is chosen, the platform performs its own deduplication across overlapping circles at the audience level — a device inside two overlapping outer circles is generally billed and reported once by the platform's own frequency logic, not twice — but that dedup logic is platform-internal and not something this conversion can verify from the geometry alone. Treat `duplicate_eligibility_area` (`duplicateEligibilityAreaM2` on the actual circle set) as the upper bound on the platform's exposure to double-counting, not as a guarantee of what the platform actually reports. ## Resolution behavior Coarser resolutions mean fewer, larger circles — cheaper against a target-count cap, but each circle's absolute overreach or underreach grows with cell size even though the relative ratio (per the single-cell circle pages) stays roughly constant for regular hexagons. Finer resolutions mean more, smaller, tighter circles that are more likely to collectively exceed a platform's target-count cap before they exceed its accuracy needs — the resolution choice here is frequently constrained by the target-count limit first and the desired precision second. ## Units and CRS Centers are EPSG:4326 decimal degrees; radii are meters unless the platform's upload API specifies otherwise (some ad platforms accept radius in miles or kilometers and round differently in each unit — verify the platform's documented unit before submitting, since a silent unit mismatch is functionally a 1.6× or 0.62× scale error on every target). ## Algorithm ``` function executeAsPointRadius(cells, mode, platformLimits): circles = [] for cell in cells: c = computeCircle(cell, mode) # inscribed | circumscribed | equalArea r = clamp(c.radiusMeters, platformLimits.minRadius, platformLimits.maxRadius) r = roundToIncrement(r, platformLimits.radiusIncrement) circles.append({ center: c.center, radiusMeters: r }) if len(circles) > platformLimits.maxTargets: circles = optimizeCircleCover(cells, platformLimits) # advanced, see below return circles ``` ```ts function toPlatformTargets( cells: string[], mode: "inscribed" | "circumscribed" | "equal_area", minRadiusM: number, radiusIncrementM: number, ) { const build = mode === "inscribed" ? inscribedCircle : mode === "circumscribed" ? circumscribedCircle : equalAreaCircle; return cells.map((cell) => { const c = build(cell); const rounded = Math.max( minRadiusM, Math.ceil(c.radiusMeters / radiusIncrementM) * radiusIncrementM, ); return { lat: c.center[0], lng: c.center[1], radiusMeters: rounded }; }); } ``` The same conversion with the Python bindings (`h3-py` v4): ```python # Reuses the same densify-and-measure routines from the inscribed/ # circumscribed circle pages; only the aggregation (min vs max vs area) # and the platform clamp/round step differ per mode. def circle_for_mode(cell: str, mode: str): center = h3.cell_to_latlng(cell) if mode == "equal_area": area_m2 = h3.cell_area(cell, unit="m^2") return center, math.sqrt(area_m2 / math.pi) boundary = h3.cell_to_boundary(cell) n = len(boundary) dists = [] for i in range(n): a, b = boundary[i], boundary[(i + 1) % n] for sample in densify_edge_geodesic(a, b): # see h3-to-inscribed-circle dists.append(h3.great_circle_distance(center, sample, unit="m")) if mode == "inscribed": return center, min(dists) * (1 - 1e-4) if mode == "circumscribed": return center, max(dists) * (1 + 1e-4) raise ValueError(f"unknown mode: {mode}") def to_platform_targets(cells, mode, min_radius_m, radius_increment_m): targets = [] for cell in cells: (lat, lng), radius_m = circle_for_mode(cell, mode) rounded = max( min_radius_m, math.ceil(radius_m / radius_increment_m) * radius_increment_m, ) targets.append({"lat": lat, "lng": lng, "radiusMeters": rounded}) return targets ``` `h3-py` provides no per-cell circle helper for any of the three modes and no minimum-radius/rounding logic — both are reproduced here: the min/max great-circle-distance aggregation over a densified boundary for inscribed/circumscribed (identical algorithm to the dedicated circle pages), the area-only formula for equal-area, and a plain `max`/`ceil` clamp-and-round for the platform floor and increment. The tested reference implementation is the TypeScript in `lib/`. ## Parameters Circle mode (inner/outer/equal-area), the platform's minimum radius, maximum radius, radius rounding increment, and maximum target count per campaign/ad-group — all platform-specific and none safe to assume from another platform's documented limits. ## Outputs A list of `{ center, radiusMeters, platformTargetId }` entries, tagged with the circle mode used and the resolution the source cell set was normalized to, so a later audit can reconstruct why a given radius does not exactly match `circumscribedCircle(cell).radiusMeters` (rounding, or a min-radius floor, will have moved it). ## Quality metrics `overreach_ratio` and `underreach_ratio` from the underlying circle mode still apply per cell, plus `duplicate_eligibility_area` across the full target set once rounding has been applied — rounding up to a minimum radius or to the next increment always increases overreach relative to the raw circle, never decreases it, since the rounding direction favors meeting the platform's floor. ## Edge cases A [minimum-radius](/docs/platform-target-count-constraints/) floor turns a small, fine-resolution cell's already-small inscribed circle into a circle much larger than the cell itself once the floor is applied — at that point the "inner" circle mode no longer has its containment guarantee, because the platform, not this conversion, has overridden the radius. Radius rounding to a platform's fixed increment (for example, whole kilometers or quarter-miles) means the delivered radius is never exactly the computed one; round up when the intent was outer/coverage-guaranteeing and round down when the intent was inner/containment-guaranteeing, and document which direction was used, since rounding in the wrong direction silently flips a construction's guarantee. ## Advanced: optimized circle cover When the naive one-circle-per-cell list exceeds a platform's target-count cap, an experimental alternative is to solve a small circle-cover problem instead: cluster nearby cell centers, replace several small circles with fewer, larger ones sized to bound total overreach, and stop once the target count is under the cap. This is the `platform_limit_optimized` conversion profile — `containmentMode: intersect`, `circleMode: circumscribed`, resolution policy "compact and coarsen until under the cap and above the minimum radius," with the profile's own stated guarantee limited to "respects `maxTargets` and `minRadius` for the named platform" and its stated tradeoff explicit: "coarsening enlarges the effective footprint." Treat this path as experimental and always report `overreach_ratio` and `coverage_ratio` against the original cell set, not just against the coarsened one, since the coarsening step itself is a second, compounding approximation on top of the circle choice. ## Assumptions and limitations This conversion assumes the platform's declared minimum radius, maximum radius, rounding increment, and target-count cap are current — these change per platform and per ad product without notice, and a value cached from a prior integration can silently violate the platform's actual current limits. It also assumes the platform's own cross-target deduplication is at least as conservative as the geometry suggests; verify this against platform documentation rather than assuming it, since `duplicate_eligibility_area` computed here is a geometric upper bound, not a report of what the platform will actually bill or attribute. --- # H3 Cell Set to Optimized Circle Cover > A heuristic greedy cover that replaces a target H3 set with fewer point+radius circles under a bounded overreach — experimental, not an optimal solver. - **URL:** https://etherdata.ai/blog/geo-interop-kb/docs/optimized-circle-cover - **Category:** h3-to-execution · **Exactness:** approximate - **Source geometry:** h3_cell_set - **Destination geometry:** point_radius - **Edge cases:** minimum-radius, radius-increments, optimized-targeting-expansion - **Related:** google-style-point-radius-execution, h3-to-circumscribed-circle, platform-target-count-constraints, h3-compaction-and-uncompaction --- experimental approximate ## Purpose One circumscribed circle per cell ([one circle per cell](/docs/google-style-point-radius-execution/)) is simple but produces exactly as many targets as cells — which fails when a platform caps the number of targets (see [target-count constraints](/docs/platform-target-count-constraints/)). This method replaces the target set with **fewer, larger circles** while bounding how much extra ground each circle adds. It is a heuristic, explicitly **not** an optimal set-cover solver. ## Source and destination geometry Source is an `h3_cell_set`; destination is a list of `point_radius` circles, one per selected disk, each carrying the target cells it covers. ## Approximation class and boundary behavior | | | |---|---| | Exactness | Approximate — a covered cell means its CENTER lies inside a circle, not that the cell is geometrically contained. | | Coverage guarantee | Under the defaults (minCoverage = 1, maxOverreach = ∞) every target cell centre is covered and circleCount ≤ cellCount. | | Overreach | Each circle adds area beyond the cells it covers; the local overreach is bounded by maxOverreach. Report it — this is not exact execution. | | Monotonicity | A tighter overreach cap never yields fewer circles than a looser one. | ## Algorithm Greedy weighted set-cover over candidate disks grown from each cell's centre: ```text uncovered = all target cells while uncovered and coverage < minCoverage: for each seed cell, for k in 0..maxK: disk = circle at seed centre reaching the k-ring cell centres covered = target cells whose centre is within the disk radius localOverreach = (π r² − Σ area(covered)) / Σ area(covered) skip if localOverreach > maxOverreach score = |covered ∩ uncovered| / (1 + localOverreach) pick the highest-scoring disk; remove its covered cells from uncovered ``` Distances are spherical (haversine) metres; areas come from h3 `cellArea` — the same spherical model as the [circle](/docs/h3-to-inscribed-circle/) methods, so radii and areas are mutually consistent. The search is O(cells² · maxK); it is intended for target sets of hundreds to low thousands of cells, not millions. ```ts // Fit a blob of cells under a target cap, accepting up to 1.5x local overreach. const cover = optimizedCircleCover(cells, { maxOverreach: 1.5, maxK: 3 }); // cover.circles: [{ center: [lat,lng], radiusMeters, coveredCells, localOverreach }] // cover.circleCount vs cover.cellCount, cover.coverageRatio, cover.uncovered ``` The tested reference implementation is the TypeScript in `lib/h3/optimized-cover.ts`. An equivalent sketch with the Python bindings (`h3-py` v4) would grow disks from `h3.cell_to_latlng` centres and measure with `h3.great_circle_distance` and `h3.cell_area`, applying the same greedy rule. ## Conservative vs expansive - **Expansive** (higher `maxOverreach`): fewer, larger circles; more duplicate eligibility and boundary spill. Pair with an explicit overreach report. - **Conservative** (lower `maxOverreach`, or seed from [inscribed circles](/docs/h3-to-inscribed-circle/)): more circles, less spill, closer to the target footprint. ## Parameters | | | |---|---| | maxK | Neighbourhood radius (grid rings) for disk growth. Default 3. | | maxOverreach | Reject candidate disks whose local overreach exceeds this. Default ∞. | | minCoverage | Stop once this fraction of cells is covered. Default 1 (all). | ## Outputs and quality metrics Outputs: the circle list plus `circleCount`, `cellCount`, `coverageRatio`, and `uncovered`. Compute [overreach and duplicate-eligibility](/docs/conversion-quality-metrics/) on the returned circles before executing — the reduction in target count is paid for in overreach, and both numbers must travel with the result. ## Known limitations - Heuristic, not optimal — a smaller admissible cover may exist. - Coverage is defined on cell centres; a covered cell can still have corner area outside its covering circle. For a hard geometric guarantee, seed radii from [circumscribed circles](/docs/h3-to-circumscribed-circle/) instead. - Ignores platform [minimum radius](/docs/platform-target-count-constraints/) and radius increments; clamp and re-measure afterwards. - No antimeridian handling in the greedy step — split trans-antimeridian sets first (see [antimeridian handling](/docs/antimeridian-handling/)). --- # H3 Compaction And Uncompaction > Losslessly replacing a complete set of sibling cells with their parent, and the exact round-trip property that makes it safe to use for storage and target-count optimization. - **URL:** https://etherdata.ai/blog/geo-interop-kb/docs/h3-compaction-and-uncompaction - **Category:** semantics · **Exactness:** exact - **Edge cases:** parent-child-duplicates, mixed-resolutions - **Related:** mixed-h3-resolutions, resolution-selection --- exact ## Purpose Compaction is a lossless rewrite of an H3 cell set: wherever a resolution-`r` cell's complete set of seven children at resolution `r+1` is present in the set, those seven children are replaced by the single parent cell. Uncompaction is the exact inverse: every cell in a (possibly mixed- resolution) set is expanded down to a single stated resolution. Both operations exist for two reasons — reducing the cell count needed to represent a target for storage, transmission, or platform target-count limits, and producing rollup summaries at a coarser resolution without re-deriving them from source geometry. ## Compaction Compaction operates purely on the H3 index hierarchy, not on the geometry the cells represent. Given a cell set, it repeatedly checks: for a candidate parent cell at resolution `r`, are all seven of its resolution-`r+1` children present in the set? If so, remove all seven children and insert the parent. This check cascades upward — a newly inserted parent may itself complete *its* parent's set of seven, and so on — so a fully compacted set can contain cells from many different resolutions at once, each representing the coarsest complete grouping available in that part of the set. A cell set with no complete sibling groups compacts to itself unchanged; compaction never removes coverage, it only changes how completely-covered regions are indexed. > **Note:** "Complete set of seven children" is a statement about the H3 indexing hierarchy, established by `cellToChildren` / `cellToParent`, not a statement about the children's polygons tiling the parent's polygon with zero gap or overlap in physical space. The two agree closely in practice but are not defined to be identical — see the containment caveat on [mixed H3 resolutions](/docs/mixed-h3-resolutions/). ## Uncompaction Uncompaction reverses this: every cell in the input, regardless of its current resolution, is expanded via `cellToChildren` down to the single target resolution requested. A cell already at the target resolution passes through unchanged; a coarser cell is expanded into all of its descendants at that resolution. The output is always a uniform-resolution set, which is why uncompaction is the standard first step before running any per-cell aggregation, area estimate, or comparison against another uniform-resolution set — see [mixed H3 resolutions](/docs/mixed-h3-resolutions/) for why skipping this step produces double-counted or incomparable results. ## The round-trip property ``` uncompact(compact(S), r) == S ``` for any cell set `S` that is already uniform at resolution `r`. This is the property that makes compaction safe for storage: compacting a set before writing it and uncompacting it back to the original resolution on read must reproduce the exact original set, cell for cell, with no loss and no drift. This round trip is a tested invariant — property-based tests generate uniform-resolution cell sets (including adversarial ones seeded near pentagons and face-crossing cells), compact them, uncompact back to the original resolution, and assert set equality against the input on every run. An implementation that fails this property is not an acceptable trade-off, it is broken. > **Note:** `uncompact(compact(S), r)` reproduces `S` only when `r` is the resolution `S` was uniform at before compaction. Uncompacting a compacted set to a coarser resolution than the original discards information (folding fine detail upward loses the finer partition); uncompacting to a finer resolution than the original manufactures cells that were never in the source set. Always uncompact to the resolution the set was compacted from unless the intent is a deliberate resolution change, in which case use [normalizeToResolution](/docs/mixed-h3-resolutions/) and treat it as a resolution conversion, not a round trip. ## Use cases Compaction is the standard technique for **target-count optimization**: many platforms cap the number of discrete geo targets accepted per campaign, and a compacted set expresses the same effective geography in far fewer rows whenever the source geometry contains large, uniformly-covered interior regions (a full county polyfilled at resolution 9 compacts to a small number of resolution-6 or resolution-5 cells for its interior, with only the boundary remaining at finer resolution). It is equally the standard technique for **reporting rollups**: a delivery report aggregated at resolution 6 can be produced directly by compacting resolution-9 delivery data, rather than re-querying source geometry at the coarser resolution. ## Algorithm ```ts // Reduce row count for storage / platform target-count limits. const compacted = compact(uniformResolution9Cells); // Recover the exact original set — property-tested round trip. const restored = uncompact(compacted, { resolution: 9 }); // restored is set-equal to uniformResolution9Cells ``` The same conversion with the Python bindings (`h3-py` v4): ```python # Reduce row count for storage / platform target-count limits. compacted = h3.compact_cells(uniform_resolution_9_cells) # Recover the exact original set — round-trip invariant, not a best effort. restored = h3.uncompact_cells(compacted, 9) assert set(restored) == set(uniform_resolution_9_cells) ``` The tested reference implementation in this knowledge base is the TypeScript in `lib/`, which asserts this round trip as a property-based test (including adversarial cell sets seeded near pentagons and face-crossing cells) rather than checking it once by hand. > Figure (mixed-resolution): Compaction replaces a full child set with its parent; parent (amber) vs children (green) footprints differ only logically. ## Parameters For `compact`: none beyond the input cell set — the algorithm always compacts maximally. For `uncompact`: the target resolution, which must be greater than or equal to the coarsest cell present in the compacted input, or the operation is undefined for any cell coarser than the requested target. ## Outputs `compact` returns a mixed-resolution cell set, typically substantially smaller in cell count than the input for geometries with large uniform interiors. `uncompact` returns a uniform-resolution cell set at the requested resolution, set-equal to the pre-compaction input when uncompacted back to the original resolution. ## Edge cases [Parent-child duplicates](/docs/mixed-h3-resolutions/) are what a broken or partial compaction leaves behind — a set that replaced some but not all of a sibling group, or that was merged with another set after compaction without re-checking for completed groups, ends up with both a parent and some of its children present simultaneously; `hasParentChildDuplicate` should be run on any compacted set before it is trusted as fully compacted. [Mixed resolutions](/docs/mixed-h3-resolutions/) are the expected, correct output shape of `compact` itself — the presence of multiple resolutions in a compacted set is not a defect, but it does mean the set must be uncompacted (or otherwise normalized) before any operation that assumes a uniform resolution. ## Assumptions and limitations Compaction assumes the input cell set is already uniform at one resolution; running `compact` on an already-mixed set (rather than uncompacting first) is only correct if the input is known to already reflect a valid partial compaction — otherwise sibling groups that exist across the mixed boundary may go undetected. Compaction reduces cell *count*; it does not change the geography represented, and it provides no benefit when the source geometry has no large uniformly-covered interior regions (a thin corridor or a boundary-heavy shape compacts to nearly its original size). ## Illustration — compaction shrinks the set, not the footprint > Figure: before: 34 cells, all R8 > Figure: after: 22 cells, mixed R6/R7/R8 --- # H3 Overview > H3 as a system: icosahedron projection, aperture-7 hierarchy, 16 resolutions, hexagon-dominant cells with 12 unavoidable pentagons, and logical (not exact) parent-child containment. - **URL:** https://etherdata.ai/blog/geo-interop-kb/docs/h3-overview - **Category:** systems · **Exactness:** approximate - **Source geometry:** h3_cell_set - **Destination geometry:** h3_cell_set - **Edge cases:** pentagons, face-crossing-cells, mixed-resolutions - **Related:** h3-pentagons, cell-system-comparison, mixed-h3-resolutions --- approximate ## What H3 is H3 is a discrete global grid system developed by Uber. It projects a regular icosahedron (20 triangular faces) onto the sphere using a gnomonic projection per face, then subdivides each face hierarchically to produce a grid of predominantly hexagonal cells at 16 resolutions, numbered 0 (coarsest) through 15 (finest). This knowledge base treats H3 as the default interoperability grid for advertising execution elsewhere, but this page describes H3 strictly as a generic cell system, on the same terms as [S2](/docs/s2-overview/) and [Geohash](/docs/geohash-overview/) — without assuming its properties are universal (see [cell-system-comparison](/docs/cell-system-comparison/)). ## Hierarchy: aperture 7 H3's subdivision scheme is "aperture 7": each cell at resolution N is covered by approximately 7 cells at resolution N+1. This is a logical, index-arithmetic relationship, not an exact geometric tiling — a parent hexagon's true boundary and the union of its 7 child cells' true boundaries are close but not identical, because the aperture-7 subdivision is not an exact area-7 partition of a hexagon. This is the single most important caveat distinguishing H3 from S2: S2's quad hierarchy gives exact geometric containment (4 children exactly tile their parent), while H3's aperture-7 hierarchy does not. Treating an H3 parent cell as if it exactly geometrically contains its children is a documented source of silent boundary error — see [mixed-h3-resolutions](/docs/mixed-h3-resolutions/) for the operational consequences when a single analysis mixes cells from more than one resolution. ## Resolutions and cell size Cell area shrinks by roughly a factor of 7 per resolution step, and edge length by roughly √7. Because H3 cells are gnomonic projections of a triangular icosahedron subdivision rather than an equal-area construction, average cell size is the only meaningful number per resolution — actual area varies by roughly 2x across the globe at a fixed resolution, largest near face centers and most distorted near face edges and vertices. | Resolution | Avg edge length (km) | Avg cell area (km²) | |---|---|---| | 0 | 1107.71 | 4,250,546.8 | | 1 | 418.68 | 607,220.9 | | 2 | 158.24 | 86,745.9 | | 3 | 59.81 | 12,392.3 | | 4 | 22.61 | 1,770.3 | | 5 | 8.54 | 252.9 | | 6 | 3.23 | 36.1 | | 7 | 1.22 | 5.16 | | 8 | 0.461 | 0.737 | | 9 | 0.174 | 0.105 | | 10 | 0.0659 | 0.0150 | | 11 | 0.0249 | 0.00215 | | 12 | 0.00942 | 0.000307 | | 13 | 0.00356 | 0.0000439 | | 14 | 0.00135 | 0.0000063 | | 15 | 0.00051 | 0.0000009 | These are the published average values (h3geo.org, last verified 2026-07-22); treat them as a planning reference, not a per-cell guarantee — any individual cell at a given resolution can differ from the average by roughly a factor of 2 due to icosahedron projection distortion. ## Hexagons, and the 12 pentagons H3 cells are hexagons almost everywhere, but exactly 12 cells per resolution are pentagons — one at each of the icosahedron's 12 vertices, where the underlying polyhedron cannot be tiled with hexagons alone (Euler's formula forces at least 12 pentagonal defects on any hexagonal tiling of a sphere-like surface). Pentagon cells have 5 neighbours instead of 6, distorted area and shape relative to neighbouring hexagons, and require explicit handling in any code that assumes "a cell has 6 neighbours" as a universal invariant. See [h3-pentagons](/docs/h3-pentagons/) for the full treatment, including which resolutions and coordinates the 12 base pentagons fall at and how they propagate to every finer resolution. ## Index representation Each H3 cell is addressed by a 64-bit integer, conventionally rendered as a 15-character hexadecimal string. The index encodes the resolution, the base cell (one of 122 base cells at resolution 0), and a sequence of per-resolution digit values describing the path down the hierarchy to the specific cell. Index bits are not simply concatenated lat/lng bits the way a Geohash string is — H3 index arithmetic is specific to H3's own subdivision scheme and does not decode meaningfully by an external system. ## Core operations | | | |---|---| | Point indexing | `latLngToCell(lat, lng, res)` maps a coordinate to its containing cell index at a given resolution. | | Polygon fill | `polygonToCells(loops, res)` produces the cell set covering a polygon at a resolution, under a containment mode (center, full, or overlapping) rather than a single canonical definition of 'covers.' | | Boundary extraction | `cellToBoundary(cell)` returns the true vertex polygon for a cell (10 vertices for a pentagon, 6 for a hexagon, plus extra vertices where a cell crosses an icosahedron face edge). | | Centroid extraction | `cellToLatLng(cell)` returns the cell's center point, used as the disk center for inscribed/circumscribed circle approximations elsewhere in this knowledge base. | | Compaction | `compactCells(cells)` / `uncompactCells(cells, res)` losslessly re-express a same-resolution cell set as a mixed-resolution set (and back), collapsing runs of 7 sibling cells into their parent where possible — see [h3-compaction-and-uncompaction](/docs/h3-compaction-and-uncompaction/). | ## What must not be assumed H3 is **not** equal-area: cell area varies by roughly 2x globally at a fixed resolution, so per-cell counts or densities must be area-normalized before comparison across regions. H3's logical parent/child containment is **not** exact geometric containment — a child cell can, in principle, extend slightly past its logical parent's true boundary, which matters for any operation assuming coarsening a cell set to a parent resolution reproduces the same covered region exactly; code needing exact geometric containment should use S2 instead, since S2's quad children exactly tile their parent. Neither property is implementation-specific; both are structural consequences of a hexagon-dominant grid on a gnomonic icosahedron projection. ## References - H3 Documentation — Uber, [h3geo.org](https://h3geo.org/) (last verified 2026-07-22) ## Assumptions and limitations The resolution/edge-length table above states average values under the system's own published geometry; treat individual-cell deviations, exact pentagon vertex coordinates, and library-version-specific behavior as requiring direct verification against `h3-js` (or the equivalent H3 binding in use) rather than this table before a production decision. ## Illustration — a real H3 cell > Figure (cell-h3): An actual R9 H3 cell (h3-js): a hexagon with 6 neighbours — except at the 12 pentagons per resolution. --- # H3 Pentagons > Twelve pentagon cells per H3 resolution sit at the icosahedron vertices and break the six-neighbour, regular-shape assumptions that most H3 code implicitly relies on. - **URL:** https://etherdata.ai/blog/geo-interop-kb/docs/h3-pentagons - **Category:** systems - **Source geometry:** h3_cell_set - **Destination geometry:** h3_cell_set - **Edge cases:** pentagons, face-crossing-cells - **Related:** h3-to-inscribed-circle, h3-to-circumscribed-circle, cell-system-comparison --- ## Purpose H3 is built by projecting a hexagonal grid onto an icosahedron and wrapping that onto the sphere. An icosahedron has 12 vertices, and a hexagonal grid cannot tile a surface with vertex curvature without a defect at each vertex — so every H3 resolution has exactly 12 pentagon cells, one at each icosahedron vertex, for every resolution from 0 to 15. This page documents what breaks when code written for "an H3 cell" implicitly means "an H3 hexagon," and states the explicit handling required. ## What breaks | | | |---|---| | Six-neighbour assumption | A hexagon has exactly 6 edge-adjacent neighbours; a pentagon has exactly 5. Any code that allocates a fixed-size array of 6 for gridDisk/neighbour results, or assumes symmetric opposite-neighbour pairs, will index incorrectly or silently drop a neighbour at a pentagon. | | Regular-shape assumption | A hexagon cell at a given resolution has a roughly consistent inscribed:circumscribed radius ratio across the globe; a pentagon does not share that ratio, and is not a regular pentagon in the geometric sense — its five sides and angles are not equal, because it is a projected, distorted shape, not a construction primitive. | | Inscribed/circumscribed ratio | A regular hexagon's inscribed-to-circumscribed radius ratio is cos(30 degrees), approximately 0.866. A pentagon's ratio is lower and is NOT a fixed constant across pentagons — it must be computed per cell from the true boundary, never assumed from the hexagon constant. | | Area consistency | H3's aperture-7 hierarchy and icosahedron projection already vary cell area by roughly a factor of two across the globe for ordinary hexagons; pentagon cells add a further, distinct area distortion at each of the 12 vertex locations, independent of the general face-projection variance. | > **Note:** Any function that computes a circle, a neighbour ring, or a shape-regularity metric from an H3 index must check `isPentagon(cell)` before applying a hexagon-derived constant. Code that hardcodes `cos(30°)` as the inscribed:circumscribed ratio, or that assumes `gridDisk(cell, 1)` always returns 7 cells (self plus 6), will produce silently wrong results at exactly 12 locations per resolution — rare enough in ad hoc testing to pass review, common enough in global-coverage production data to appear in every large campaign. ## Detection `isPentagon(cell)` (h3-js v4) returns whether a given cell index is one of the 12 pentagons at its resolution. `getPentagons(resolution)` returns the full list of the 12 pentagon cell indexes at that resolution directly, useful for pre-flagging a dataset before any per-cell shape computation runs rather than checking every cell individually. ```ts const pentagonsAtRes8 = getPentagons(8); // exactly 12 cell indexes const flagged = cellSet.map((cell) => ({ cell, isPentagon: isPentagon(cell), })); ``` The same enumeration with the Python bindings (`h3-py` v4): ```python pentagons_at_res8 = h3.get_pentagons(8) # exactly 12 cell ids flagged = [(c, h3.is_pentagon(c)) for c in cell_set] ``` ## Where the 12 pentagons fall — land vs water The icosahedron underlying H3 is deliberately oriented so its 12 vertices — and therefore all 12 pentagons — sit in the ocean. This is not folklore: locating `getPentagons(res)` for every resolution and testing each cell **center** against Natural Earth 110m land shows **all 12 pentagon centers are over water at every resolution 0 through 15** (zero land centers). The choice keeps the pentagon distortion off inhabited, high-inventory geography. > Figure (pentagons-world): The 12 pentagon centers at R2 plotted on the coarse land mask — all over water by center; two clip a coastline by footprint. The nuance is footprint, not center. A resolution-0 pentagon spans thousands of kilometres, so even with its center in open water its **boundary** can clip a coastline. Flagging whether each pentagon's boundary intersects land (still Natural Earth 110m) shows the effect shrinking as cells shrink: | | | |---|---| | Centers over land (all resolutions) | 0 of 12 — the design guarantee. | | Boundary clips land · R0 | 8 of 12 — the base pentagons are enormous. | | Boundary clips land · R1 / R2 | 5 / 4 of 12. | | Boundary clips land · R3 / R4 | 2 / 2 of 12. | | Boundary clips land · R5 and finer | 0 of 12 — small enough to sit entirely in open water. | So from roughly R5 onward the 12 pentagons are fully offshore; only at very coarse resolutions (R0–R4) does a pentagon footprint touch land at all (at R2, the four that clip a coast fall on Norway, the Bohai/Yellow Sea coast, western Australia, and the Argentine shelf). The full per-resolution table with every cell id, center, and both flags is generated to `public/fixtures/pentagons.json` by `scripts/gen-pentagons.ts`. ```ts // How the flags are produced (see scripts/gen-pentagons.ts): const rows = getPentagons(res).map((cell) => { const [lat, lng] = cellToLatLng(cell); const onLand = land.features.some((f) => turf.booleanPointInPolygon(turf.point([lng, lat]), f), ); return { cell, lat, lng, centerOnLand: onLand }; }); ``` ```python # Equivalent with h3-py v4 + shapely (land = a shapely prepared land geometry): from shapely.geometry import Point def pentagons_land_flags(res, land): rows = [] for cell in h3.get_pentagons(res): lat, lng = h3.cell_to_latlng(cell) rows.append({"cell": cell, "lat": lat, "lng": lng, "center_on_land": land.contains(Point(lng, lat))}) return rows ``` > **Note:** Classification uses Natural Earth 1:110m land, which omits small islands, so "water" means "not on a 110m landmass," not "provably open ocean." A pentagon center near a small island could read as water. For land-sensitive work re-run `gen-pentagons.ts` against a finer land polygon. ## Effects on downstream conversions - **Circle approximation** ([h3-to-inscribed-circle](/docs/h3-to-inscribed-circle/), [h3-to-circumscribed-circle](/docs/h3-to-circumscribed-circle/)): both `inscribedCircle` and `circumscribedCircle` compute radius from the true, great-circle-densified boundary — min geodesic distance for inscribed, max for circumscribed — rather than from a nominal edge length, which is the only reason they remain correct on pentagons at all. Any circle function that instead derives radius from edge length times a hexagon constant will under- or over-state the inscribed/circumscribed radius for the 12 pentagon cells at each resolution. - **Polyfill**: `polygonToH3` in `full` or `intersect` mode treats a pentagon cell like any other cell for containment testing — the boundary ring is simply five vertices instead of six — so polyfill correctness is unaffected. What is affected is any post-polyfill shape-regularity assumption applied uniformly across the result set. - **Neighbour operations**: `gridDisk`, `gridRingUnsafe`, and compaction logic that assumes a fixed ring size per k-distance will return fewer cells at a k-ring centered on or adjacent to a pentagon, because a pentagon has 5 immediate neighbours, not 6, and the standard ring-size formula (`3k² + 3k + 1` for a hexagon-only disk) does not hold once a pentagon is inside the disk radius. ## Face-crossing cells (a related, distinct distortion) Pentagon cells sit at icosahedron vertices; a second, related distortion — [face-crossing-cells](/docs/cell-system-comparison/) — affects ordinary hexagon cells whose area happens to straddle two icosahedron faces. These cells are not pentagons and pass `isPentagon` as false, but their edges are asymmetric and their inscribed:circumscribed ratio deviates from the resolution norm for the same underlying reason: local projection distortion near a geometric singularity of the icosahedron. Detection for this case is not a boolean flag but a comparison of the cell's actual inscribed and circumscribed radii against the resolution's typical hexagon values — a ratio significantly below the hexagon norm indicates a face-crossing or otherwise distorted cell even when `isPentagon` returns false. ## Guidance Handle pentagons explicitly rather than filtering them out: they are valid, permanent members of every resolution's cell set (12 per resolution, not an error condition), and any campaign with sufficient geographic scope will include one eventually. Their centers all fall over water (see the land/water section above), and from resolution 5 onward their footprints are fully offshore, so pentagons rarely coincide with dense land inventory — but vertex placement is fixed by the icosahedron construction, so a Pacific, polar, or coastal cell set can still contain one. Report per-cell shape regularity (the computed inscribed:circumscribed ratio) alongside any circle or area conversion rather than assuming a resolution-level constant, and never hardcode the hexagon ratio of cos(30 degrees) in a function that will also receive pentagon input. ## Assumptions and limitations This page assumes h3-js v4 semantics for `isPentagon` and `getPentagons`. --- # H3 To Administrative Crosswalk > Preserving every cell-to-region relationship a boundary crossing creates, instead of collapsing a straddling H3 cell to a single administrative owner. - **URL:** https://etherdata.ai/blog/geo-interop-kb/docs/h3-to-administrative-crosswalk - **Category:** crosswalks · **Exactness:** weighted - **Source geometry:** h3_cell_set - **Destination geometry:** admin_county, census_geo, dma - **Edge cases:** duplicated-region-ids, stale-boundaries - **Related:** administrative-polygon-to-h3, conversion-quality-metrics --- weighted ## Purpose A crosswalk answers a different question than the partition built in [administrative polygon to H3](/docs/administrative-polygon-to-h3/): not "which single region owns this cell" but "which regions does this cell touch, and by how much." It exists for downstream uses that need to *split* a quantity — population, spend, audience, screen inventory — across regions in proportion to real overlap, rather than force each cell into one bucket. ## Source geometry and destination geometry Source geometry is an `h3_cell_set`, normalized to EPSG:4326. Destination geometry is one or more administrative region types — `admin_county`, `census_geo` (tract or block group), or `dma` — each a polygon or multipolygon keyed by a stable region id (FIPS, GEOID, DMA code). ## Exactness class This conversion is **weighted**: it does not resolve to one right answer per cell, but a distribution of a cell's membership across every region it geometrically intersects, expressed as fractional weights. Two crosswalks built from the same cells and region polygons but a different weighting basis (raw area versus population) legitimately assign different shares of the same cell to the same region, and both are correct for their stated basis. ## Containment rule and boundary behavior — membership rules | | | |---|---| | centroid | Cell belongs to the region whose polygon contains the cell center. Single-owner, cheap, blind to how much of the cell lies outside that region. | | largest-overlap | Cell assigned to whichever region holds the largest share of its area. Single-owner, area-aware rather than point-aware. | | any-overlap | Cell listed against every region it intersects at all, no fraction attached. Many-to-many, boolean — eligibility, not apportionment. | | full-containment | Cell listed against a region only if the whole cell lies inside it. Straddling cells belong to no region alone; pair with a partial rule to avoid gaps. | | area-weighted | Every (cell, region) pair retained, weight equal to intersection_area / cell area. Assumes uniform area density inside the cell. | | population-weighted | Weight is region_population times (intersection_area / region_total_area). Assumes uniform population density, false near urban cores at fine resolution. | | audience-weighted | Same interpolation, apportioning a platform or panel audience count instead of census population — subject to the panel's own coverage bias. | | inventory-weighted | Weight is a count of physical or media units (screens, store fronts) intersecting the cell, apportioned by area or unit point locations. | | probabilistic | Weight drawn from an exposure or gravity model instead of assumed-uniform area — road density, measured foot traffic. Only as good as that model's validation. | ## Why the many-to-many relationship must be preserved A cell that straddles two regions is not an edge case to be resolved away — it is the geometric reality of overlaying a hexagonal grid on polygons whose boundaries were drawn without regard to that grid. Collapsing a straddling cell to a single `argmax` region discards the minority share entirely. Consider a cell split 70/30 between County A and County B: an `argmax` crosswalk assigns 100% of the cell to County A, which is now **overcounted** by the 30% it never held, while County B is **undercounted** by the 30% it did hold — in the same operation, on the same cell. Aggregated over every boundary cell along a county line, this is not noise but a systematic bias: smaller regions sharing a long boundary with a larger neighbor lose share every time, in the same direction. A many-to-many crosswalk is the only representation that lets a caller reconstruct the true split later — a single-owner table cannot be repaired downstream once the discarded fraction is gone. ## Resolution behavior Coarser resolutions have fewer, larger cells, so a higher fraction of the cells adjacent to any boundary straddle it. Finer resolutions reduce that fraction — cell area shrinks roughly sevenfold per step while boundary- adjacent cell count grows only with the boundary's length — but the fraction never reaches zero at any finite resolution, since a boundary is a continuous curve and the grid is discrete. A crosswalk is required at every resolution; what changes is the total misallocated area a single-owner rule would incur, not whether the problem exists. ## Units and CRS Region polygons and H3 cell boundaries are normalized to EPSG:4326 before intersection. `intersection_area` is computed as spherical (haversine- consistent) m², matching `cellArea(cell, "m2")`, so `cell_coverage_fraction` and `region_coverage_fraction` stay dimensionless ratios of comparable area units rather than a mix of projected and unprojected area. ## Algorithm ```ts // Many-to-many: every (cell, region) pair the cell actually touches, // retained with an area-based weight. const crosswalk = weightedCrosswalk(cells, regionPolygons, { resolution: 8, weightBy: "area", // or "population" | "audience" | "inventory" }); // Single-owner reference view, DERIVED from the same overlaps — // never treat this as the source of truth for apportionment. const primaryRegion = maxOverlapAssignment(cells, regionPolygons, { resolution: 8, }); ``` The same conversion with the Python bindings (`h3-py` v4): ```python from collections import defaultdict from shapely.geometry import Polygon def cell_polygon(cell: str) -> Polygon: # h3-py returns (lat, lng) pairs; shapely expects (x, y) = (lng, lat). boundary = h3.cell_to_boundary(cell) return Polygon([(lng, lat) for lat, lng in boundary]) def weighted_crosswalk(cells, region_polygons: dict[str, Polygon]): # Many-to-many: every (cell, region) pair the cell actually touches, # retained with an area-based weight. rows = [] for cell in cells: cell_poly = cell_polygon(cell) cell_area = cell_poly.area for region_id, region_poly in region_polygons.items(): overlap = cell_poly.intersection(region_poly).area if overlap > 0: rows.append({ "cell_id": cell, "region_id": region_id, "cell_coverage_fraction": overlap / cell_area, }) return rows def max_overlap_assignment(cells, region_polygons: dict[str, Polygon]): # Single-owner reference view, DERIVED from the same overlaps — an # argmax over each cell's rows, not a separate source of truth. best = defaultdict(lambda: (None, 0.0)) for row in weighted_crosswalk(cells, region_polygons): cell, region, frac = row["cell_id"], row["region_id"], row["cell_coverage_fraction"] if frac > best[cell][1]: best[cell] = (region, frac) return {cell: region for cell, (region, _) in best.items()} ``` `shapely` here computes planar area on lat/lng coordinates, which is only adequate for illustration at this scale — the tested reference implementation in this knowledge base is the TypeScript in `lib/`, which uses spherical (haversine-consistent) area throughout. > Figure (admin-crosswalk): Two adjacent regions at R8; boundary cells assigned by max overlap (blue=west, pink=east). ## Parameters Resolution, weighting basis (`area`, `population`, `audience`, `inventory`, or a supplied `probabilistic` model), region polygon vintage, and whether `full-containment` rows are emitted alongside partial rows. ## Outputs A weighted crosswalk table: `cell_id`, `source_region_id`, `intersection_area`, `cell_coverage_fraction` (intersection_area divided by cell area), `region_coverage_fraction` (intersection_area divided by region area), and, when a non-area basis is requested, an added weight column (`population_weight`, `audience_weight`, `inventory_weight`, or `probability_weight`). A cell touching three regions produces three rows. ## Quality metrics For each cell, the sum of `cell_coverage_fraction` across its rows should equal 1.0 within floating-point tolerance (typically 1e-6) if the region set is a true partition; a sum below 1.0 indicates a gap under the cell, and a sum above 1.0 indicates overlapping region polygons — both are per-cell diagnostics worth surfacing before the crosswalk ships. Compute `coverage_ratio`, `overreach_ratio`, and `jaccard` per region against its source polygon to confirm the crosswalk's apportioned area tracks the region's true area. ## Edge cases [Duplicated region ids](/docs/geometry-catalogue/) occur when a multipart region (an island county, a DMA split by a lake) is stored as multiple ring records sharing one id — deduplicating on region id before intersecting drops coverage from the smaller part. [Stale boundaries](/docs/geometry-catalogue/) are the more common failure: DMA and census vintages change year over year, so a crosswalk built against last year's polygons misapportions every cell near a moved boundary with no error raised — `boundary_vintage` must be recorded and checked against what the destination expects. ## Assumptions and limitations Area- and population-weighted crosswalks assume uniform density within the source region for whatever is being apportioned; this degrades visibly at coarse resolution near dense urban boundaries, where population is not remotely uniform across a county. When better information exists — a gravity model, foot-traffic panel, inventory point locations — prefer `probabilistic` or point-apportioned `inventory-weighted` over an area default. ## Illustration — weighted vs argmax > Figure (weighted-crosswalk): Every (cell, region) overlap is kept; opacity is the cell coverage fraction, so cells straddling the seam fade — the information a max-overlap partition throws away. --- # H3 To Circumscribed Circle > Approximating an H3 cell with the smallest centered disk that fully contains it, guaranteeing coverage at the cost of overlap with neighboring cells - **URL:** https://etherdata.ai/blog/geo-interop-kb/docs/h3-to-circumscribed-circle - **Category:** h3-to-execution · **Exactness:** expansive - **Source geometry:** h3_cell_set - **Destination geometry:** point_radius - **Edge cases:** pentagons - **Related:** h3-to-inscribed-circle, h3-to-equal-area-circle, google-style-point-radius-execution --- expansive ## Purpose When a point-radius execution surface must guarantee that no part of a targeted cell is left unreached — a proximity alert, a store-visit radius, a coverage-complete media buy — the circumscribed circle is the correct construction. It trades the opposite direction from the inscribed circle: it never undershoots the cell, at the cost of claiming ground outside it and overlapping every neighboring cell's own circumscribed circle. ## Source geometry and destination geometry Source is an `h3_cell_set`, one circle computed per cell. Destination is `point_radius`: a center latitude/longitude and a radius in meters, one pair per input cell. ## Definition Center is the H3 cell center (`cellToLatLng`). Radius is the **maximum** geodesic distance from that center to any point on the cell's true boundary. For a hexagonal cell this maximum is attained exactly at a vertex — unlike the inscribed case, using the vertex set here is directionally correct, not a bug — but the implementation still densifies each edge with great-circle samples before taking the maximum, because that same routine must also be correct for distorted, non-regular, and pentagon cells where the true maximum can, in principle, sit off-vertex under numerical perturbation. A relative `SAFETY_MARGIN = 1e-4` is added to the sampled maximum so the guarantee holds even between samples and against floating-point error in the underlying trig. ## Robust algorithm ``` function circumscribedRadius(cell): center = cellCenter(cell) boundary = cellBoundary(cell) # great-circle arcs, vertex list samples = densifyGreatCircle(boundary, perEdge=64) maxDist = 0 for p in samples: d = haversineDistance(center, p) if d > maxDist: maxDist = d return maxDist * (1 + 1e-4) # SAFETY_MARGIN growth ``` ```ts const approx = circumscribedCircle(cell); // approx.center: [lat, lng] // approx.radiusMeters: maximum center->boundary distance, margin-grown // approx.cellAreaM2, approx.circleAreaM2, approx.isPentagon ``` The same conversion with the Python bindings (`h3-py` v4): ```python DEFAULT_EDGE_SAMPLES = 64 SAFETY_MARGIN = 1e-4 def _to_unit_vector(lat: float, lng: float): lat_r, lng_r = math.radians(lat), math.radians(lng) return ( math.cos(lat_r) * math.cos(lng_r), math.cos(lat_r) * math.sin(lng_r), math.sin(lat_r), ) def _to_latlng(v): x, y, z = v return (math.degrees(math.asin(z)), math.degrees(math.atan2(y, x))) def _slerp(a, b, t: float): # Great-circle interpolation between two unit vectors — the same # densify step used on the inscribed-circle page, reused here because # the maximum, like the minimum, must be measured against the full # densified boundary, not just the vertex list. dot = max(-1.0, min(1.0, sum(ai * bi for ai, bi in zip(a, b)))) theta = math.acos(dot) if theta == 0: return a sin_theta = math.sin(theta) wa = math.sin((1 - t) * theta) / sin_theta wb = math.sin(t * theta) / sin_theta return tuple(wa * ai + wb * bi for ai, bi in zip(a, b)) def densify_edge_geodesic(a, b, samples: int = DEFAULT_EDGE_SAMPLES): va, vb = _to_unit_vector(*a), _to_unit_vector(*b) return [_to_latlng(_slerp(va, vb, i / samples)) for i in range(samples + 1)] def circumscribed_radius_m(cell: str) -> float: center = h3.cell_to_latlng(cell) boundary = h3.cell_to_boundary(cell) n = len(boundary) max_dist = 0.0 for i in range(n): a, b = boundary[i], boundary[(i + 1) % n] for sample in densify_edge_geodesic(a, b): d = h3.great_circle_distance(center, sample, unit="m") if d > max_dist: max_dist = d return max_dist * (1 + SAFETY_MARGIN) # grow so the guarantee holds between samples ``` `h3-py` has no `circumscribedCircle` helper; this mirrors the same densify-then-measure approach as the inscribed-circle page but takes the **maximum** center-to-sample `great_circle_distance` instead of the minimum, then grows it by the `1e-4` safety margin instead of shrinking it. For a regular hexagon the true maximum lands on a vertex, but the algorithm still densifies every edge rather than checking only the vertex list, so it stays correct for pentagons and distorted cells too. The tested reference implementation is the TypeScript in `lib/`. > Figure (circumscribed-circle): The circumscribed circle reaches the farthest vertex: r=205.7 m for this R9 cell. ## Containment guarantee Every point of the true H3 cell lies inside the circumscribed disk: `cell ⊆ circumscribed disk`. This is the mirror-image guarantee of the inscribed circle's `⊆` in the other direction, and it holds under the same spherical model and the same `1e-4` safety margin against the finite-sample approximation. ## Resolution behavior Radius scales with cell edge length, shrinking by roughly `sqrt(7) ≈ 2.65×` per resolution step, same as the inscribed circle. The *relative* overreach — overreach area as a fraction of cell area — is resolution-invariant for regular hexagons, since it is a function of cell shape, not size; the *absolute* duplicate-eligibility area shrinks with resolution even as the relative figure holds steady. ## Units and CRS Center and boundary coordinates are `[lat, lng]` (h3-js native order), EPSG:4326. Distances are spherical (haversine) meters on the same `R_MEAN = 6,371,008.8 m` sphere `cellArea` uses. ## Quality metrics | | | |---|---| | overreach_ratio | area(circumscribed disk − cell) / area(cell). For a regular hexagon this is approximately 0.209 — the disk is about 20.9% larger than the cell it circumscribes. | | duplicate_eligibility_area | computed via duplicateEligibilityAreaM2(circles) — total area double-counted across a set of overlapping circumscribed circles: Σ area(disk_i) − area(union of disks). Non-zero whenever any two neighboring cells' circumscribed circles overlap, which is every adjacent pair by construction. | For a regular hexagon, circumradius equals the cell's own "radius" `R` (center to vertex), so `circleAreaM2 = π R²` against a hexagon area of `(3√3/2) R² ≈ 2.598 R²`, giving a disk/cell area ratio of about `1.209` — consistent with the `cos(30°) ≈ 0.866` radius ratio between this construction and the [inscribed circle](/docs/h3-to-inscribed-circle/): `r_in / r_out = cos(30°)` for a regular hexagon, so the two constructions bound the true cell area from below (0.907× cell area) and above (1.209× cell area) respectively, with the cell itself sitting strictly between. ## Duplicate eligibility and experiment contamination Because every circumscribed circle extends past its cell's true boundary, adjacent circles overlap in a band along every shared edge. Any ground in that band is eligible under two (or, near a hexagon corner, three) circles at once. This is a measurement hazard, not merely a targeting inefficiency: a device or household in an overlap band can be counted as reached by two "different" cells in a report, inflating apparent reach and corrupting per-cell frequency capping. `duplicateEligibilityAreaM2` quantifies this directly from the set of circle features actually used, rather than from a theoretical hexagon — call it after generating the full circle set for a campaign, not per-cell in isolation, since the metric is inherently a property of the *set*. > **Note:** If circumscribed circles are used to define treatment cells in a geo-lift test, the overlap band between a treatment cell and an adjacent control cell means the control cell is partially exposed to the treatment circle's radius. This leaks treatment into control and biases the measured lift downward. Use the [inscribed circle](/docs/h3-to-inscribed-circle/), not this construction, whenever cells must remain mutually exclusive for measurement purposes. ## Edge cases [Pentagons](/docs/h3-pentagons/) have five, shorter, less regular edges, and their circumradius is measured relative to a smaller, less regular cell area, so the overreach ratio at the 12 pentagon cells per resolution deviates further from the regular-hexagon figure above — flag them (`isPentagon` on the result) and do not average them into a fleet-wide overreach estimate. Cells whose boundary crosses an icosahedron face seam are still handled correctly by the distance-to-boundary maximum, since the algorithm makes no planarity assumption. ## Assumptions and limitations The circumscribed circle guarantees coverage but never non-overlap; treating a set of circumscribed circles as a partition (for exclusive budget allocation, for instance) will overcount the ground in every overlap band. Use `duplicate_eligibility_area` to size that overcounting before committing a media plan or a measurement design to this construction, and prefer the [equal-area circle](/docs/h3-to-equal-area-circle/) when the requirement is area-representative reach rather than guaranteed coverage. --- # H3 To Equal Area Circle > Approximating an H3 cell with a disk of the same area for reach and planning estimates, with no containment guarantee in either direction - **URL:** https://etherdata.ai/blog/geo-interop-kb/docs/h3-to-equal-area-circle - **Category:** h3-to-execution · **Exactness:** approximate - **Source geometry:** h3_cell_set - **Destination geometry:** point_radius - **Related:** h3-to-inscribed-circle, h3-to-circumscribed-circle --- approximate ## Purpose Planning tools — reach estimators, budget allocators, market-sizing dashboards — often need a single representative circle per cell whose *area* matches the cell, without caring whether that circle sits inside or outside the true boundary. The equal-area circle is that construction. It is the right tool for area-weighted reach math and the wrong tool for anything that will be executed as a real target, because it makes no containment claim at all. ## Source geometry and destination geometry Source is an `h3_cell_set`, one circle computed per cell. Destination is `point_radius`: a center latitude/longitude and a radius in meters, one pair per input cell. ## Definition Center is the H3 cell center. Radius is derived directly from cell area, with no boundary sampling at all: $$ r = \sqrt{\frac{\text{cell area}}{\pi}} $$ where `cell area` is `cellArea(cell, "m2")`, the h3-js spherical-model area of the true cell. No `SAFETY_MARGIN`, no edge densification, and no distance-to-boundary computation is involved — this is the only one of the three circle constructions that does not touch the cell's boundary geometry at all. ## Containment guarantee — there is none > **Note:** The equal-area circle is not a subset of the cell and the cell is not a subset of the circle. Because a disk and a hexagon are different shapes, matching their areas forces the disk to extend beyond the hexagon's edges in some directions (near the edge midpoints, where the hexagon is "thinnest" relative to a disk of the same area) while falling short of the hexagon's corners in others. Gaps and overlaps exist **simultaneously** in the same circle, not as alternative failure modes — this is the defining property of the construction, not an edge case of it. ## Resolution behavior Radius scales with `sqrt(cell area)`, and cell area shrinks by exactly 7× per resolution step in H3's aperture-7 hierarchy, so radius shrinks by `sqrt(7) ≈ 2.65×` per step — identical scaling behavior to the inscribed and circumscribed radii. The simultaneous-gap-and-overlap property is scale-invariant: it is a function of matching a disk's area to a hexagon's area, independent of how large either one is. ## Units and CRS Center is `[lat, lng]` (h3-js native order), EPSG:4326. `cellArea` uses h3-js's spherical model; the radius derived from it is therefore a spherical-model radius, consistent with the inscribed and circumscribed constructions but not with an ellipsoidal (WGS84) area computation. ## Algorithm ``` function equalAreaRadius(cell): area = cellArea(cell) # h3 spherical-model area, m^2 return sqrt(area / pi) ``` ```ts const approx = equalAreaCircle(cell); // approx.center: [lat, lng] // approx.radiusMeters: sqrt(cellAreaM2 / Math.PI) // approx.cellAreaM2 === approx.circleAreaM2 (by construction, up to fp error) // approx.mode === "equal_area" ``` The same conversion with the Python bindings (`h3-py` v4): ```python def equal_area_radius_m(cell: str) -> float: area_m2 = h3.cell_area(cell, unit="m^2") return math.sqrt(area_m2 / math.pi) ``` Unlike the inscribed and circumscribed pages, this one really is a one-liner in both languages — `h3.cell_area` (h3-py's spherical-model area, same model as h3-js's `cellArea`) is the only call involved, with no boundary densification or safety margin. The tested reference implementation is the TypeScript in `lib/`. > Figure (three-circles): inscribed (172 m) ≤ equal-area (184 m) ≤ circumscribed (206 m) for one R9 cell. ## Parameters None beyond the cell itself — there is no edge-sample count or safety margin to configure, since the construction never inspects the boundary. ## Outputs A center and radius per cell, with `cellAreaM2` equal to `circleAreaM2` by construction (the entire point of the method), which also means area-based quality metrics computed against the *cell itself* — as opposed to against a neighbor or a requested polygon — are close to meaningless here: of course the areas match, that is the definition, not a result. ## Quality metrics `coverage_ratio`, `overreach_ratio`, and `underreach_ratio` computed against the true cell boundary are all simultaneously non-zero and, for a regular hexagon, roughly balanced: the circle covers most of the cell interior, misses a thin sliver near each corner, and extends past the boundary near each edge midpoint by a comparable amount, so `overreach_ratio` and `underreach_ratio` are both small but nonzero and neither approaches the 0% figure that the exact-polygon page reports or the near-10%/20% one-directional figures the inscribed and circumscribed pages report. `jaccard` (area of intersection over area of union with the true cell) is the single most informative summary statistic here, since it captures both effects at once. ## Radius ordering For a regular hexagon the three constructions in this section order as: $$ r_{\text{inscribed}} \; (\approx 0.866R) \; \le \; r_{\text{equal-area}} \; (\approx 0.909R) \; \le \; r_{\text{circumscribed}} \; (= R) $$ where `R` is the hexagon's circumradius. The equal-area radius sits between the other two for every regular cell, which is a useful sanity check when validating a new implementation: if a computed equal-area radius falls outside the `[inscribed, circumscribed]` interval for a given cell, something in the area or radius computation is wrong. ## Edge cases No edge cases are tracked separately for this conversion: because the construction never samples the boundary, it is unaffected by pentagon irregularity, icosahedron face-crossings, or antimeridian wrapping in the way the boundary-sampling constructions are — `cellArea` and `cellToLatLng` already handle those correctly inside h3-js. The construction's weakness is not in edge handling; it is in the fundamental non-containment property above. ## Assumptions and limitations Never treat an equal-area circle as an execution target when the requirement is "reach exactly this cell" or "do not reach outside this cell" — it satisfies neither. It is appropriate for aggregate planning math (summed reach estimates across many cells, where the per-cell gaps and overlaps partially cancel across a large footprint) and inappropriate as the geometry actually handed to a delivery platform; for that, use the [inscribed](/docs/h3-to-inscribed-circle/) or [circumscribed](/docs/h3-to-circumscribed-circle/) circle depending on whether the platform-side risk is under- or over-targeting. --- # H3 To Exact Polygon > Rendering an H3 cell set as its true GeoJSON boundary, with no radius approximation and no area lost or gained - **URL:** https://etherdata.ai/blog/geo-interop-kb/docs/h3-to-exact-polygon - **Category:** h3-to-execution · **Exactness:** exact - **Source geometry:** h3_cell_set - **Edge cases:** antimeridian, pentagons, face-crossing-cells - **Related:** h3-to-inscribed-circle, h3-to-circumscribed-circle, h3-to-equal-area-circle, antimeridian-handling, h3-pentagons, geometry-normalization --- exact ## Purpose Some execution targets accept an arbitrary polygon rather than a circle or a platform-native id: a DSP with a polygon-upload geofence product, an internal reporting join, a map render, or an audit artifact that must show exactly what an H3 cell set covers. This page converts an H3 cell set to its true boundary geometry — no radius is fitted, no area is added or removed. It is the reference every circle-approximation page in this section is measured against. ## Source geometry and destination geometry Source geometry is an `h3_cell_set`: one or more H3 cell ids at a stated resolution. The destination is not one of the catalogued geometry types in this knowledge base — it is a GeoJSON `Polygon` or `MultiPolygon` Feature, built by dissolving the shared edges of adjacent cells into a single outer ring (with inner rings for any enclosed holes). Because that output is the *exact* cell footprint rather than an approximating shape, it does not carry a separate destination-geometry id; it is described in prose here rather than tagged. ## Exactness class This conversion is **exact**: the resulting polygon's boundary coincides with the true edges of the input cells to within floating-point precision. Unlike every other conversion in this section, there is no coverage/overreach trade-off to report — `coverage_ratio = 1`, `overreach_ratio = 0`, `underreach_ratio = 0`, `jaccard = 1`, by construction, provided the output is not simplified afterward. ## Containment rule and boundary behavior The rule is definitional: the output polygon contains exactly the union of the input cells' true spherical boundaries, no more and no less. Two mechanics make this correct rather than approximate: | | | |---|---| | Ring dissolve | Shared edges between adjacent cells in the input set cancel out; only edges on the outer perimeter (or around an interior hole left by a missing cell) remain in the output rings. | | Ring closure | Every output ring must repeat its first coordinate as its last coordinate. h3-js's cellsToMultiPolygon already emits closed rings; hand-built rings from cellToBoundary do not, and must be closed before use in GeoJSON consumers or turf. | Two coordinate-order pitfalls sit directly on this containment rule, not adjacent to it — get them wrong and the "exact" polygon is silently mangled: > **Note:** `cellToBoundary` returns vertices as `[lat, lng]` pairs — h3-js's native order. GeoJSON, and every downstream consumer of this polygon (turf, Mapbox, a DSP's polygon-upload endpoint), expects `[lng, lat]`. Swapping the pair order rather than transposing it produces a polygon that is a mirror image across the equator/prime-meridian axis, not a shifted one — it will look plausible on a map at low zoom and be wrong everywhere. Always call `cellsToMultiPolygon(cells, true)` (the `isGeoJson` flag) or transpose explicitly; never assume order. ## Resolution behavior Resolution changes the *density* of the boundary approximation of the true curved cell edges, not the containment rule. H3 cell edges are great-circle arcs, not planar lines; a GeoJSON ring is a sequence of straight (rhumb, effectively planar-interpolated) segments between vertices. At coarse resolutions (res 4–6) a single edge can span tens of kilometers, and the chord between its two vertices deviates from the true great-circle arc by a measurable amount — this is real, not a rendering artifact, and matters for any downstream intersection test at those resolutions. At fine resolutions (res 9+) edges are short enough that the chord-arc deviation is sub-meter and usually ignorable. If a caller needs the arc itself rather than the chord, densify each edge with great-circle samples before emitting the ring (see `densifyRingGeodesic` in the inscribed/circumscribed circle pages) — this trades exactness-of-vertex-count for exactness-of-shape. > Figure (h3-to-polygon): One R9 cell as a closed GeoJSON ring: 6 vertices (gold) around the center (cyan). ## Units and CRS Output is EPSG:4326, `[lng, lat]` decimal degrees, closed rings. `cellArea(cell, "m2")` and any turf area computation on the output ring are consistent with each other because both assume a spherical model rather than an ellipsoidal one; treat area comparisons as spherical m², not survey-grade WGS84 m². ## Algorithm ```ts // Single cell -> closed GeoJSON ring, [lng,lat], via explicit transpose. function cellToRing(cell: string): [number, number][] { const boundary = cellToBoundary(cell) as [number, number][]; // [lat,lng] const ring: [number, number][] = boundary.map(([lat, lng]) => [lng, lat]); ring.push(ring[0]!); // close the ring return ring; } // Dissolved outline for an entire cell set -> GeoJSON MultiPolygon. function cellSetToPolygon(cells: string[]): Feature { const coords = cellsToMultiPolygon(cells, true); // isGeoJson=true => [lng,lat] return { type: "Feature", properties: {}, geometry: { type: "MultiPolygon", coordinates: coords } }; } ``` The same conversion with the Python bindings (`h3-py` v4): ```python # Single cell -> closed ring, h3-py's native (lat, lng) order. def cell_to_ring(cell: str) -> list[tuple[float, float]]: boundary = list(h3.cell_to_boundary(cell)) # ((lat, lng), ...), open ring boundary.append(boundary[0]) # close the ring explicitly return boundary # Dissolved outline for an entire cell set -> LatLngMultiPoly. def cell_set_to_shape(cells: list[str]): return h3.cells_to_h3shape(cells, tight=True) # LatLngMultiPoly, outer + hole rings ``` Note the ordering: `h3.cell_to_boundary` returns `(lat, lng)` pairs and, unlike `cellsToMultiPolygon(cells, true)` in h3-js, does not offer a `geo_json` flag in v4 — flip to `(lng, lat)` yourself before handing coordinates to GeoJSON/shapely, and `cells_to_h3shape` likewise returns `LatLngPoly`/`LatLngMultiPoly` objects in `(lat, lng)` order, not GeoJSON directly. The tested reference implementation for this conversion is the TypeScript in `lib/`. ## Parameters The cell set and its resolution; an optional `densify` sample count per edge if the caller needs great-circle-accurate arcs rather than chords; an optional simplification tolerance (see below). ## Outputs A GeoJSON `Polygon` or `MultiPolygon` Feature, EPSG:4326, closed rings, right-hand winding as produced by h3-js. Interior holes appear as additional rings when the input set has an enclosed gap. ## Quality metrics None are meaningful in the coverage sense — `coverage_ratio`, `overreach_ratio`, and `underreach_ratio` are all trivially 1, 0, 0 for an un-simplified output. The metric worth tracking instead is vertex count before/after any simplification step, since that is the only thing that can push this conversion out of the exact class. ## Edge cases Pentagons contribute five edges instead of six to the dissolve; no special handling is required for a correct ring-dissolve implementation, but a naive hex-only renderer that assumes six vertices per cell will corrupt a pentagon's ring. Cells whose true boundary crosses an icosahedron face seam have a boundary vertex sequence that is still valid but locally non-convex-looking near the seam; this is expected geometry, not a bug. [Antimeridian](/docs/antimeridian-handling/)-crossing cell sets produce rings whose longitude sign flips across ±180°; a naive consumer that does not split the ring at the antimeridian will render a polygon that wraps the entire globe instead of the small area actually covered — split into two polygons at ±180° before handing the result to a renderer or a spatial join that assumes a single unsplit ring. ## Assumptions and limitations This conversion assumes the caller wants the true footprint, not a simplified one. Any topology simplification pass (Douglas-Peucker, `turf.simplify`, or a platform's own polygon-upload simplifier) trades exactness for vertex count and moves this conversion from `exact` to `approximate` — measure `coverage_ratio` and `overreach_ratio` after simplification, since a "small" simplification tolerance in degrees can still cut off or add several percent of area at coarse resolutions where individual edges are long. --- # H3 To Inscribed Circle > Approximating an H3 cell with the largest centered disk that stays fully inside it, for platforms that only take a point and a radius - **URL:** https://etherdata.ai/blog/geo-interop-kb/docs/h3-to-inscribed-circle - **Category:** h3-to-execution · **Exactness:** conservative - **Source geometry:** h3_cell_set - **Destination geometry:** point_radius - **Edge cases:** pentagons, face-crossing-cells, antimeridian - **Related:** h3-to-circumscribed-circle, h3-to-equal-area-circle, requested-vs-executed-geography --- conservative ## Purpose Many execution surfaces — mobile SDKs, DOOH proof-of-play radii, legacy DSPs — accept only a point and a radius, never a polygon. The inscribed circle is the point-radius approximation of an H3 cell that never claims ground the cell does not contain: it is the correct choice whenever the requirement is "do not target outside this cell," including mutually exclusive treatment/control cells in an experiment. ## Source geometry and destination geometry Source is an `h3_cell_set`, one circle computed per cell. Destination is `point_radius`: a center latitude/longitude and a radius in meters, one pair per input cell. ## Definition Center is the H3 cell center (`cellToLatLng`). Radius is the **minimum** geodesic distance from that center to any point on the cell's true boundary — not the nearest vertex. > **Note:** The closest point on a hexagonal cell's boundary to its center is not a vertex — for a regular cell it is the midpoint of the nearest edge, roughly 13% closer to the center than the nearest vertex is. If the inscribed radius is computed as the minimum distance to the six (or five, for a pentagon) *vertices* rather than to the full boundary, the resulting circle is measurably too large: it will extend past the true edge midpoint and into the neighboring cell. That breaks the entire reason to use an inscribed circle — the `⊆` guarantee — and does so silently, since the error is small enough to pass casual visual inspection on a map. The boundary, not the vertex set, is the correct reference; approximate the boundary by densifying each edge rather than trusting the vertices alone. ## Robust algorithm Each of the cell's edges is a great-circle arc between two vertices, not a straight line in lat/lng space. The algorithm densifies every edge with `DEFAULT_EDGE_SAMPLES = 64` evenly spaced great-circle (slerp) samples, computes the haversine distance from the center to every sample, and takes the minimum. A relative `SAFETY_MARGIN = 1e-4` shrink is then applied to that minimum so the guarantee holds *between* the finite samples, not only at them — the true continuous minimum could fall slightly closer to center than any single sampled point, and the margin is sized to dominate the observed sub-`1e-5` inter-sample error by an order of magnitude. ``` function inscribedRadius(cell): center = cellCenter(cell) boundary = cellBoundary(cell) # great-circle arcs, vertex list samples = densifyGreatCircle(boundary, perEdge=64) minDist = +infinity for p in samples: d = haversineDistance(center, p) if d < minDist: minDist = d return minDist * (1 - 1e-4) # SAFETY_MARGIN shrink ``` ```ts const approx = inscribedCircle(cell); // approx.center: [lat, lng] // approx.radiusMeters: minimum center->boundary distance, margin-shrunk // approx.cellAreaM2, approx.circleAreaM2, approx.isPentagon ``` The same conversion with the Python bindings (`h3-py` v4): ```python DEFAULT_EDGE_SAMPLES = 64 SAFETY_MARGIN = 1e-4 def _to_unit_vector(lat: float, lng: float): lat_r, lng_r = math.radians(lat), math.radians(lng) return ( math.cos(lat_r) * math.cos(lng_r), math.cos(lat_r) * math.sin(lng_r), math.sin(lat_r), ) def _to_latlng(v): x, y, z = v return (math.degrees(math.asin(z)), math.degrees(math.atan2(y, x))) def _slerp(a, b, t: float): # Spherical linear interpolation between two unit vectors; this is the # great-circle equivalent of a lerp, and what "densify with great-circle # (slerp) samples" means concretely — h3-py has no built-in densify call. dot = max(-1.0, min(1.0, sum(ai * bi for ai, bi in zip(a, b)))) theta = math.acos(dot) if theta == 0: return a sin_theta = math.sin(theta) wa = math.sin((1 - t) * theta) / sin_theta wb = math.sin(t * theta) / sin_theta return tuple(wa * ai + wb * bi for ai, bi in zip(a, b)) def densify_edge_geodesic(a, b, samples: int = DEFAULT_EDGE_SAMPLES): va, vb = _to_unit_vector(*a), _to_unit_vector(*b) return [_to_latlng(_slerp(va, vb, i / samples)) for i in range(samples + 1)] def inscribed_radius_m(cell: str) -> float: center = h3.cell_to_latlng(cell) boundary = h3.cell_to_boundary(cell) # vertices only — NOT the full boundary n = len(boundary) min_dist = float("inf") for i in range(n): a, b = boundary[i], boundary[(i + 1) % n] for sample in densify_edge_geodesic(a, b): d = h3.great_circle_distance(center, sample, unit="m") if d < min_dist: min_dist = d return min_dist * (1 - SAFETY_MARGIN) # shrink so the guarantee holds between samples ``` `h3-py` does not ship an `inscribedCircle` helper or a densify function — this reproduces the algorithm with core calls: `cell_to_boundary` for the vertex list, a great-circle (slerp) densify per edge, and `great_circle_distance` for each sample-to-center distance, taking the **minimum** across all densified samples and shrinking it by the same `1e-4` safety margin. Do not shortcut this to "minimum distance to a vertex" — that silently produces a circle too large to guarantee `⊆ cell`, exactly as the danger callout above describes. The tested reference implementation is the TypeScript in `lib/`. > Figure (inscribed-circle): The inscribed circle touches edge midpoints, not vertices: r=171.7 m for this R9 cell. ## Containment guarantee Every point of the inscribed disk lies inside the true H3 cell: `inscribed disk ⊆ cell`. This holds under the spherical model used throughout (haversine distance, `cellArea` on the same model) and within the `1e-4` safety margin against the finite-sample approximation of the boundary. It is the only circle construction in this section whose guarantee runs in this direction. ## Resolution behavior Radius scales with cell edge length, which shrinks roughly by `sqrt(7) ≈ 2.65×` per resolution step. Finer resolutions give proportionally smaller inscribed circles and proportionally smaller *absolute* uncovered corner area, but the *relative* underreach (uncovered area as a fraction of the cell) is resolution-invariant for regular hexagons — it is a function of cell shape, not cell size. ## Units and CRS Center and boundary coordinates are `[lat, lng]` (h3-js native order), EPSG:4326. Distances are spherical (haversine) meters on `R_MEAN = 6,371,008.8 m`, the same sphere `cellArea` uses, so radius and area figures are mutually consistent — not ellipsoidal (WGS84) survey distances. ## Quality metrics | | | |---|---| | underreach_ratio | area(cell − inscribed disk) / area(cell). Always greater than zero for a hexagon; this is the uncovered-corner cost, not a defect. | | uncovered_area | area(cell − inscribed disk) in absolute m², useful when comparing across mixed resolutions where the ratio alone hides magnitude. | For a perfectly regular hexagon the *radius* ratio inscribed/circumscribed is exactly $\cos(30°) \approx 0.866$ (apothem over circumradius) — real H3 cells are only approximately regular, so measured ratios cluster near but not exactly at this value, and pentagons and distorted cells sit further from it. The area comparison that matters for `underreach_ratio` is disk area against *cell* area, not disk against disk: $$ \frac{\text{inscribed disk area}}{\text{cell area}} = \frac{\pi r_{in}^2}{\text{cell area}} = \frac{\pi \cos^2(30°)}{3\sqrt{3}/2} \approx 0.907 $$ for a regular hexagon — the inscribed disk covers about 90.7% of the cell, leaving `underreach_ratio ≈ 0.093`: roughly 9% of a regular hexagon's area, concentrated in its six corners, sits outside the inscribed disk. That 9% is the structural cost of the `⊆` guarantee, not a rounding error, and it is what makes the inscribed circle unsuitable whenever "complete coverage" is the actual requirement. ## Edge cases [Pentagons](/docs/h3-pentagons/) have five, shorter, less regular edges; their inscribed radius is smaller relative to cell area than a hexagon's, so the underreach ratio is measurably worse at the 12 pentagon cells per resolution — flag them (`isPentagon` on the result) rather than silently averaging them into a fleet-wide radius estimate. Cells whose boundary crosses an icosahedron face seam are handled correctly by the distance-to-boundary algorithm (it makes no planarity assumption), but any downstream code that assumes a "typical" hexagon shape for these cells will be wrong. [Antimeridian](/docs/antimeridian-handling/)-crossing cells need longitude unwrapped before any lat/lng-based bounding logic runs; the haversine distance calculation itself is unaffected because it works in 3-D angle terms, not planar longitude differences. ## Assumptions and limitations Two neighboring inscribed circles never overlap with each other in the sense that matters for experiment isolation — no ground is double-covered — but they also do not tile the cell layer: uncovered gaps exist at every cell's corners and are not claimed by any neighbor's circle either. That makes the inscribed circle the right choice for mutually exclusive treatment cells and the wrong choice for "complete coverage" requirements, which belong on the [circumscribed circle](/docs/h3-to-circumscribed-circle/) page instead. --- # 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. - **URL:** https://etherdata.ai/blog/geo-interop-kb/docs/h3-to-platform-native-geography - **Category:** crosswalks · **Exactness:** approximate - **Source geometry:** h3_cell_set - **Destination geometry:** platform_geo_id - **Edge cases:** platform-native-ids-only, stale-boundaries - **Related:** requested-vs-executed-geography, platform-target-count-constraints --- platform-dependent ## 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](/docs/platform-target-count-constraints/) below) and carries lower confidence than a mapping built from a published boundary file. ## Adapter pipeline ```mermaid flowchart LR H["H3 cell set
(source resolution)"] --> U["Normalized polygon union
(dissolve, repair, EPSG:4326)"] U --> X["Versioned platform
geography crosswalk"] X --> P["Platform-native IDs
(+ 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 ```ts // 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): ```python 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](/docs/platform-target-count-constraints/) — 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](/docs/geometry-catalogue/) 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. --- # Inclusion And Exclusion Semantics > How include and exclude geographies combine into one effective target, and why the combination has to happen in a single normalized cell space before anything else runs. - **URL:** https://etherdata.ai/blog/geo-interop-kb/docs/inclusion-and-exclusion-semantics - **Category:** semantics · **Exactness:** approximate - **Edge cases:** unsupported-exclusions, mixed-resolutions - **Related:** mixed-h3-resolutions, requested-vs-executed-geography --- approximate ## Purpose A geographic target is rarely a single region. It is usually stated as one or more inclusions minus one or more exclusions — "target this state, but not this city" — and every layer of the stack from planning through execution has to agree on what that combination means before any of it can be measured. This page defines the effective-geography operation and the failure modes that show up when it is computed carelessly, or not computed at all before comparing two geographies. ## The effective geography $$ \text{effective} = \bigcup(\text{inclusions}) \setminus \bigcup(\text{exclusions}) $$ Every inclusion is unioned into one set, every exclusion is unioned into a second set, and the effective target is the first set with the second subtracted out. This is a set operation, not a geometric one performed polygon-against-polygon — both sides must already be expressed as sets of H3 cells at a common resolution before the union and difference are evaluated, for reasons covered below. ## Cases | | | |---|---| | Include country, exclude city | A large polygon minus a small polygon nested inside it. The excluded city's cells are removed from the country's cell set; cells outside the city are untouched. | | Include cells, exclude ZIPs | An H3 cell set with a postal-code exclusion. The ZIP polygons must first be polyfilled to the same resolution as the included cells before the subtraction is meaningful. | | Include polygon, exclude point-radius | A polygon inclusion with a circular exclusion cut out of it — for example, a trade area with a competitor's buffer removed. Both sides are normalized to H3 before the difference. | | Include parent cells, exclude child cells | An inclusion stated at a coarse resolution with an exclusion stated at a finer resolution nested inside it. Requires resolution normalization first; see mixed-resolution handling below. | | Mixed-resolution include/exclude | Inclusions and exclusions supplied at different H3 resolutions in the same request — common when one side comes from a compacted set and the other from a fixed-resolution polyfill. | | Overlapping source systems | Inclusions or exclusions sourced from two systems whose boundary vintages disagree (e.g., last quarter's DMA file for inclusion, this quarter's for exclusion), producing a difference that reflects boundary drift rather than intended targeting. | | Unsupported exclusions | An exclusion the execution platform cannot express at all. The platform silently drops it rather than erroring, so reported geography and executed geography diverge without any signal in the platform's own logs. | ## Why normalization must happen first Union and set-difference are only well-defined operations on two sets drawn from the same universe. If inclusions are H3 cells at resolution 8 and exclusions are a ZIP polygon that has not been polyfilled, "subtract" is not a computable operation yet — there is no shared unit to subtract in. Every inclusion and exclusion source (polygon, point-radius, raw H3 cells at whatever resolution they arrived in) must be converted into H3 cells at one common working resolution before the union or the difference is taken. Doing the subtraction on raw geometry first and converting to H3 second produces a different, non-reproducible result depending on which geometry library performed the subtraction — the order matters, and normalize-then-combine is the only order that is reproducible from the H3 grid alone. ## Precedence, empty results, and dangling exclusions Exclusion always wins: a cell present in both the inclusion union and the exclusion union is removed, with no configuration that reverses this precedence — an "include and exclude the same cell" request is not ambiguous, it resolves to excluded. An effective geography can legitimately be empty (the exclusion union fully covers the inclusion union); this must be surfaced as an explicit empty-result state distinct from "no exclusions were supplied," since a downstream system that treats both cases the same way will silently launch against zero geography instead of raising an error. A **dangling exclusion** — an exclusion whose cells never intersected any inclusion cell in the first place — has no effect on the result but should still be reported, because it usually indicates a targeting mismatch (the two sides were built from misaligned assumptions about what the inclusion actually covers) worth surfacing even though it changed nothing. ## Algorithm ```ts // Normalizes every input to one resolution, then applies // effective = union(inclusions) - union(exclusions). const effective = effectiveGeography( { inclusions, exclusions }, { resolution: 8 } ); if (effective.cells.length === 0) { // Explicit empty-result state — not the same as "no exclusions given." flagEmptyEffectiveGeography(effective); } ``` The same conversion with the Python bindings (`h3-py` v4): ```python 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 effective_geography(inclusions, exclusions, resolution: int = 8) -> set[str]: include_set = normalize_to_resolution(inclusions, resolution) exclude_set = normalize_to_resolution(exclusions, resolution) # Plain Python set difference — well-defined only because both sides # were normalized to the same resolution above. return include_set - exclude_set effective = effective_geography(inclusions, exclusions, resolution=8) if not effective: # Explicit empty-result state — not the same as "no exclusions given." flag_empty_effective_geography() ``` The tested reference implementation in this knowledge base is the TypeScript in `lib/`; this Python mirrors its normalize-then-combine order using core `h3-py` calls plus ordinary Python set operations on string cell ids. > Figure (include-exclude): A 19-cell inclusion disk minus its center: 18 effective cells (pink = excluded). ## Edge cases [Unsupported exclusions](/docs/platform-target-count-constraints/) are the most consequential failure mode here: when a platform cannot express an exclusion (no negative-targeting primitive, or a cap on the number of exclusion regions it accepts), it drops the exclusion rather than rejecting the request, so what the platform reports as delivered geography silently includes territory the requester believed was excluded. This must be detected before launch by checking the platform's exclusion support against the request, not discovered afterward by comparing delivery logs to intent. [Mixed resolutions](/docs/mixed-h3-resolutions/) between the inclusion and exclusion sides are common whenever one side comes from a compacted cell set and the other from a fixed-resolution polyfill — normalizing both sides to one resolution, as `effectiveGeography` does internally, is mandatory before any comparison, union, or subtraction is attempted. ## Assumptions and limitations This model assumes exclusions are geometric — describable as a set of H3 cells — and does not cover attribute-based exclusion (for example, "exclude households on a suppression list"), which operates on a different axis than geography and should not be folded into the same set-difference without first confirming the platform treats the two axes independently. --- # Line And Corridor To H3 > Four distinct ways to turn a road segment or device trajectory into H3 cells, and why GPS noise makes the choice consequential. - **URL:** https://etherdata.ai/blog/geo-interop-kb/docs/line-and-corridor-to-h3 - **Category:** source-to-h3 · **Exactness:** approximate - **Source geometry:** line_road, trajectory - **Destination geometry:** h3_cell_set - **Edge cases:** gps-noise, boundary-oscillation, device-trajectory-exposure - **Related:** arbitrary-polygon-to-h3, point-to-h3, point-radius-to-h3, conversion-quality-metrics --- approximate ## Purpose Roads and device trajectories are one-dimensional (or effectively zero-width) geometries, which makes "convert to H3" ambiguous in a way polygons are not: a line has no interior to test cell centers against. This page distinguishes four legitimate conversions — intersection, sampling, buffered corridor, and network-distance corridor — and covers the trajectory-specific problems (noise, oscillation, dwell) that dominate in practice. ## Source geometry and destination geometry Source geometry is `line_road` (a static road-network segment or polyline) or `trajectory` (an ordered, timestamped sequence of device positions). Destination geometry is an `h3_cell_set`, or — for trajectories — an **ordered** H3 sequence with per-cell entry and exit timestamps, which is a materially richer object than an unordered cell set. ## Exactness class Approximate for all four variants below; a 1-dimensional line has zero width and therefore zero area, so any polygon-style containment fraction is either trivially zero or requires an explicit corridor width to become meaningful. ## Containment rule and boundary behavior | | | |---|---| | Intersection | Include every H3 cell that the line geometry passes through, regardless of how short the intersection segment is. Cheapest and most common; corresponds to intersect-mode polygon containment applied to a zero-width geometry. | | Sampling | Interpolate points along the line at a fixed step (e.g. every 25 m) and take the H3 cell of each sampled point. Cheaper to compute for very long lines but can skip cells the line passes through between samples if step size exceeds cell diameter. | | Buffered corridor | Buffer the line by a stated half-width into a polygon, then polyfill that polygon under any of the four polygon containment modes. Turns a width-less line into a genuine area-bearing corridor with a defensible coverage_ratio. | | Network-distance corridor | Include cells within a stated network-travel-distance (drive time or drive distance along the road graph) of the line, not Euclidean distance. Matches real accessibility catchments but requires a routable network graph, not just geometry. | For trajectories specifically, the output is not just a cell set but an **ordered sequence**: consecutive positions map to consecutive H3 cells (with repeats where the device stays in one cell), each annotated with entry timestamp, exit timestamp, and derived dwell time. Direction of travel is recoverable from the sequence order and should be preserved rather than collapsing the trajectory into a set before it is needed. > Figure (line-corridor): A polyline buffered to a 120 m corridor, filled at R10 (intersect): 81 cells. ## Resolution behavior Intersection and sampling both produce thinner, more line-like cell sets at higher resolution and thicker, more blob-like sets at lower resolution, since a coarse cell straddling the line drags in area on both sides. Buffered-corridor coverage_ratio behaves like any polygon polyfill: it tightens toward the true corridor area as resolution increases. For trajectories, resolution also controls dwell-time granularity — at a resolution where consecutive pings fall in the same cell, dwell time is computed correctly as the span between entry and exit; at a resolution finer than the noise floor of the position data, a stationary device can appear to hop between adjacent cells purely from GPS jitter (see boundary-oscillation, below), fragmenting what should be one dwell event into many. ## Units and CRS EPSG:4326 for geometry; corridor buffer widths and network distances in meters; timestamps in UTC with explicit timezone handling for any day-part or dwell analysis performed downstream. Network-distance corridors require the routing graph's own distance units (often already meters) to be reconciled with the buffer width's units before comparison. ## Algorithm ```ts // Intersection: cells the line touches at all const intersected = polygonToH3(lineAsZeroWidthGeometry, { resolution: 9, mode: "intersect", }); // Buffered corridor: give the line width, then polyfill normally const corridor = polygonToH3(bufferLine(roadSegment, 100 /* m half-width */), { resolution: 9, mode: "intersect", }); // Trajectory: ordered cell sequence with dwell function trajectoryToCellSequence(pings, resolution) { const seq = pings.map((p) => ({ cell: latLngToCell(p.lat, p.lng, resolution), t: p.timestamp, })); return collapseConsecutiveDuplicates(seq); // merges repeats into entry/exit/dwell } ``` The same conversion with the Python bindings (`h3-py` v4): ```python from shapely.geometry import LineString, Point from shapely.ops import transform def buffer_corridor(line_lat_lng, half_width_m): # Buffer the line (in an azimuthal-equidistant frame centered on its # midpoint) into a corridor polygon, then polyfill it like any # arbitrary polygon. lats, lngs = zip(*line_lat_lng) mid_lat, mid_lng = sum(lats) / len(lats), sum(lngs) / len(lngs) aeqd = pyproj.CRS.from_proj4(f"+proj=aeqd +lat_0={mid_lat} +lon_0={mid_lng} +units=m") to_aeqd = pyproj.Transformer.from_crs("EPSG:4326", aeqd, always_xy=True).transform to_wgs84 = pyproj.Transformer.from_crs(aeqd, "EPSG:4326", always_xy=True).transform line_m = transform(to_aeqd, LineString([(lng, lat) for lat, lng in line_lat_lng])) corridor_m = line_m.buffer(half_width_m) corridor_deg = transform(to_wgs84, corridor_m) return [(lat, lng) for lng, lat in corridor_deg.exterior.coords] def corridor_to_h3(line_lat_lng, half_width_m, res): ring = buffer_corridor(line_lat_lng, half_width_m) return h3.polygon_to_cells(h3.LatLngPoly(ring), res) # center containment; # for intersect/full/threshold, classify candidates with shapely area # overlap exactly as on the arbitrary-polygon page. # Ordered sequence for a trajectory, with grid_path/grid_distance available # to interpolate or measure between non-adjacent cells in the sequence. def trajectory_to_cell_sequence(pings, res): seq = [(h3.latlng_to_cell(p["lat"], p["lng"], res), p["t"]) for p in pings] # h3.grid_distance(a, b) reports hop count between two cells in the # sequence; h3.grid_path(a, b) fills in the cells presumed traversed # between two non-adjacent samples (e.g. a coarse sampling step). return collapse_consecutive_duplicates(seq) ``` The tested reference implementation for this conversion is the TypeScript in `lib/`; the Python above mirrors its buffer-then-polyfill and sequence-collapsing logic with core `h3-py` calls. ## Parameters Resolution, conversion variant (`intersection`, `sampling`, `buffered corridor`, `network-distance corridor`), corridor half-width or network distance, sampling step (sampling variant only), and — for trajectories — a smoothing window applied before cell assignment. ## Outputs For static lines: an `h3_cell_set`, optionally with `coverage_ratio` against a buffered reference corridor. For trajectories: an ordered list of `{cell_id, entry_ts, exit_ts, dwell_seconds}` records, one per contiguous occupancy of a cell, plus a derived direction-of-travel field between consecutive distinct cells. ## Quality metrics For buffered corridors: standard `coverage_ratio`, `overreach_ratio`, `jaccard` against the buffer polygon. For trajectories: cell-hop rate (hops per minute) as a proxy for noise — an implausibly high hop rate for a device's stated speed indicates boundary oscillation rather than genuine movement — and dwell-event count before and after smoothing, to quantify how much fragmentation smoothing removed. ## Edge cases GPS noise is the dominant real-world problem: consumer-grade GPS has a typical accuracy radius of 5-15 meters in open sky and 20-50+ meters in urban canyons, which is frequently larger than a res-10 or res-11 cell, so a stationary device's raw pings can scatter across several neighboring cells even with zero actual movement. Boundary oscillation compounds this specifically near a cell edge: a device whose true position sits within noise-distance of a cell boundary can flip between two cells ping to ping, manufacturing false dwell events and false direction changes; the standard mitigation is trajectory smoothing (a moving average or a map-matching step against the road network) applied before cell assignment, not after — smoothing the cell sequence itself cannot undo damage already done by assigning noisy points independently. Device-trajectory-exposure is a privacy and governance edge case, not a purely geometric one: an ordered, timestamped cell sequence at fine resolution is a de-anonymization vector for identifying home and work locations, and any pipeline producing this output should apply the resolution floors and aggregation minimums described in [privacy and minimum aggregation](/docs/privacy-and-minimum-aggregation/) before the sequence leaves a controlled environment. ## Assumptions and limitations This conversion assumes the caller has decided which of the four variants answers their actual question before running it — "does this campaign touch this road" (intersection), "what's the footprint of a 200m buffer around this corridor" (buffered corridor), or "what did this device do" (trajectory) are different questions with different correct answers, and none substitutes for another. Network-distance corridors additionally assume a maintained, routable road graph is available; where one is not, a buffered corridor with a conservative width is the honest fallback, not a silent substitute presented as network-aware. --- # Lines And Trajectories > Unordered polylines versus ordered, timestamped position sequences — roads and rivers on one side, device journeys on the other, with very different exposure profiles. - **URL:** https://etherdata.ai/blog/geo-interop-kb/docs/lines-and-trajectories - **Category:** geometries - **Source geometry:** line_road, trajectory - **Edge cases:** gps-noise, boundary-oscillation, device-trajectory-exposure - **Related:** line-and-corridor-to-h3, privacy-and-minimum-aggregation, points, arbitrary-polygons --- This family covers two geometries that share a shape — a sequence of connected coordinates — but differ in the one property that determines how they must be handled: order. A road centerline or a river is an unordered (or arbitrarily ordered) polyline; traversal direction is a property of the thing it represents, not of the geometry file. A device trajectory is an ordered, timestamped sequence of positions where the order **is** the information — a home→highway→store sequence and a store→highway→home sequence are the same three points in a different, meaningful order. That difference is why trajectories carry a materially higher privacy exposure than any other member of this catalogue, addressed further down. | | | |---|---| | Cardinality | Unordered for roads and rivers; strictly ordered and timestamped for trajectories | | Governed by | A mapping vendor for roads/transit; the recording device for trajectories | | Not a geometry | A route name or transit-line id, distinct from its actual path geometry | | Converts via | Line/corridor intersection against the H3 grid, order preserved when present | ## Members | Member | Ordered? | What it represents | |---|---|---| | Road centerline | No (direction is attribute data, not sequence) | A street or highway segment | | Transit line | Yes (route direction matters) | A bus/rail route path | | River | No | A natural watercourse | | Utility network line | No | A pipeline, cable, or conduit run | | Device trajectory | Yes, plus timestamps | An individual's or vehicle's journey | | Journey / trip | Yes, plus timestamps | A trajectory bounded to one origin-destination trip | ## Required metadata | Field | Why it's required | |---|---| | CRS | Standard EPSG:4326 normalization, as with any other geometry family | | Direction (lines) | Needed for transit and utility-flow semantics even though the geometry itself may be stored unordered | | Timestamps (trajectories) | What makes a trajectory a trajectory rather than an unordered line; without them, order is unverifiable | | Sampling rate (trajectories) | Determines achievable resolution and how much GPS noise to expect between fixes | | Consent state (trajectories) | Governs both the resolution and the retention window a trajectory may legally be processed at | ## Common risks **GPS noise and boundary oscillation** affect both members, but bite hardest on trajectories: a noisy fix sitting near a cell edge flips the assigned cell back and forth between two neighbors on successive samples, inflating a transition count that never actually happened; roads and rivers suffer the corridor-scale version of the same problem, where a path skimming a cell edge produces a jagged, duplicated cell sequence rather than a clean corridor. Both are mitigated the same way — smoothing, snapping within the accuracy radius, and debouncing repeated A→B→A transitions — before the sequence is trusted for corridor analysis. **Direction loss**: treating a transit line or utility network as undirected when direction is operationally meaningful (which platform serves which stop first, which way current or flow moves) silently drops information the geometry file never encoded in the first place; direction has to be carried as attribute data alongside the line, not inferred from vertex order. > **Note:** An ordered, timestamped, high-resolution trajectory can uniquely identify a person from a home/work pattern alone, independent of any single point's individual precision. This risk does not exist for an unordered road or river line, and it does not exist for a single point in isolation — it is specific to the ordering and density of positions in a trajectory. Coarsen space or time, drop the ordering, or aggregate to origin-destination pairs before trajectories leave a privacy boundary; see [privacy and minimum aggregation](/docs/privacy-and-minimum-aggregation/). ## How it converts to H3 Both members convert by intersecting the line against the H3 grid — a plain line-to-cell intersection for roads and rivers, or a buffered-corridor fill when the line needs to be treated as a band with width — documented on [line and corridor to H3](/docs/line-and-corridor-to-h3/). Trajectories use the same geometric intersection but additionally require the ordering and timestamps to be carried through the conversion so that dwell extraction and sequence analysis remain possible downstream; converting a trajectory without preserving order collapses it into an unordered multipoint and should be treated as a [multipoints](/docs/multipoints/) case instead, with the ordering loss recorded explicitly rather than silently dropped. --- # Mixed H3 Resolutions > Why a set containing H3 cells from more than one resolution cannot be compared or subtracted until every cell is normalized to a single resolution. - **URL:** https://etherdata.ai/blog/geo-interop-kb/docs/mixed-h3-resolutions - **Category:** semantics · **Exactness:** exact - **Edge cases:** mixed-resolutions, parent-child-duplicates - **Related:** h3-compaction-and-uncompaction, inclusion-and-exclusion-semantics --- exact ## 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](/docs/h3-compaction-and-uncompaction/): 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 ```ts 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): ```python 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. > Figure (mixed-resolution): An R7 parent (amber) overlaid with R9 children (green): logical hierarchy is not exact geometric containment. ## Edge cases [Mixed resolutions](/docs/inclusion-and-exclusion-semantics/) 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](/docs/h3-compaction-and-uncompaction/) 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. --- # Multipoints > A collection of independent point observations rather than one coherent shape — bid requests, visits, and conversions aggregated to cells, where duplication and sparsity are the dominant failure modes. - **URL:** https://etherdata.ai/blog/geo-interop-kb/docs/multipoints - **Category:** geometries - **Source geometry:** multipoint_audience - **Edge cases:** duplicate-observations, sparse-audience-suppression - **Related:** point-to-h3, points, privacy-and-minimum-aggregation, conversion-quality-metrics --- A multipoint is a collection of point observations that are counted or aggregated together but do not describe one coherent shape the way a polygon does — an hour of bid requests, a week of store visits, a month of conversions. Each observation is a member of the [points](/docs/points/) family individually; what makes this a distinct geometry family is the aggregate: the questions asked of a multipoint (how many observations per cell, how many distinct entities, what's the density) are different from the questions asked of any single point, and answering them correctly depends on getting deduplication and suppression right before the count is ever reported. | | | |---|---| | Cardinality | A variable-size set of observations over a window, not one shape | | Governed by | The aggregation window and deduplication logic applied, not an external authority | | Not a geometry | A device or cookie id — the audience is the aggregate of resolved points | | Converts via | Per-observation point-to-cell, then dedup, count, and suppress at the cell level | ## Members | Member | What each observation represents | Typical volume | |---|---|---| | Audience observations | A device or cookie's recorded presence | Millions per market per period | | Bid requests | An ad-exchange auction event's location field | Very high volume, high duplication risk | | Store visits | A resolved visit-to-POI event | Moderate volume, tied to a dwell/visit model | | Conversions | A purchase or app-event location | Lower volume, often the measurement target | | Event observations | Any other logged occurrence with a coordinate | Varies by source | ## Required metadata | Field | Why it's required | |---|---| | CRS | Standard normalization requirement shared with all point-based geometries | | Per-observation timestamp | Needed to define the aggregation window and detect staleness within it | | Deduplication key | Without one, the same impression or visit can be counted more than once per cell | | Entity identifier (where privacy-permitted) | Distinguishes "10 observations from 1 device" from "10 observations from 10 devices" — a materially different audience signal | ## Common risks **Duplicate observations** are the dominant risk: the same impression, visit, or conversion logged more than once — from retries, multi-source ingestion, or an upstream join fanning out — inflates the count or audience size attributed to a cell, and the effect compounds with volume, since high-volume feeds like bidstream data are also the ones most prone to duplicate delivery. A dedup key plus a time window is the standard defense; without one, "volume per cell" silently becomes "volume per cell times an unknown, non-uniform duplication factor," which corrupts any comparison across cells with different duplication rates. **Sparse-cell suppression** follows once the multipoint is aggregated: a cell with very few distinct observations risks re-identifying the individuals behind them, and must be suppressed or rolled up to a coarser resolution rather than reported as-is — this is the same minimum-aggregation requirement that governs device pings and trajectories, applied at the aggregate rather than the individual level. **Entity-vs-observation conflation** is a related but separate error: reporting raw observation counts as if they were unique-entity counts overstates audience size whenever any entity contributes more than one observation in the window, which is the common case, not the exception. > **Note:** There is no boundary to this family the way there is for a polygon — "the geometry" of a multipoint is entirely a function of which observations were included, over what time window, after what deduplication. Two multipoints built from the identical underlying event stream but different windows or dedup logic are not comparable cell-for-cell, even though both are legitimately "the same audience." ## How it converts to H3 Each observation converts individually with the same `latLngToCell` mechanics as [point to H3](/docs/point-to-h3/); what differs at the multipoint level is what happens after conversion — grouping by cell, deduplicating within the window, counting distinct entities rather than raw rows, and applying sparse-cell suppression before the aggregate is reported. See [privacy and minimum aggregation](/docs/privacy-and-minimum-aggregation/) for the suppression thresholds, and [conversion quality metrics](/docs/conversion-quality-metrics/) for how duplication and suppression should be surfaced alongside the resulting per-cell counts rather than left implicit. --- # Platform Identifiers > Opaque or standardized ids — FIPS, ISO, DMA, publisher market codes — that reference a geometry through a versioned crosswalk but are never a geometry themselves. The central case for this catalogue's one recurring warning. - **URL:** https://etherdata.ai/blog/geo-interop-kb/docs/platform-identifiers - **Category:** geometries - **Source geometry:** platform_geo_id - **Edge cases:** platform-native-ids-only, stale-boundaries, asynchronous-boundary-updates - **Related:** h3-to-platform-native-geography, administrative-boundaries, geometry-catalogue --- This page exists to state, as directly as possible, the rule the rest of this catalogue keeps returning to: **an identifier is not a geometry.** Every other family in this catalogue is a shape, or a rule for constructing one. This family is a label — a string or code that some system uses to refer to a geographic region — and the region it refers to is not encoded in the label at all. It is encoded in a boundary file, at a specific vintage, resolved through a specific crosswalk. Two systems can hold the identical identifier and disagree completely about what territory it covers, because the label never changes even when the boundary underneath it does. | | | |---|---| | Cardinality | A label, not a shape — one id per region per namespace | | Governed by | Whichever platform or standard minted the id (FIPS, ISO, DMA, publisher) | | Not a geometry | The id itself — the recurring warning this whole page exists to state | | Converts via | No direct conversion; H3 resolves outward through a maintained, versioned crosswalk | ## Members | Member | What it is | Typical namespace | |---|---|---| | Postal id | A postal-service delivery-area code | ZIP, ZCTA, national postcode | | FIPS code | US Census/federal region code | FIPS state/county code | | ISO code | International country/subdivision code | ISO 3166-1/2 | | DMA id | Nielsen media-market code | DMA code | | Publisher id | A media owner's own market label | Publisher-internal | | Internal market identifier | A company's own named region | Internal, often undocumented outside the owning team | ## Required metadata | Field | Why it's required | |---|---| | Id namespace | The same string can mean different things in different systems — a bare `"501"` is meaningless without knowing which namespace produced it | | Boundary vintage | The version of the boundary set the id was minted against — this is what actually determines the geometry, not the id itself | | Crosswalk source | The specific mapping table or service used to resolve the id to a boundary, since two crosswalks built from different vintages will disagree | ## Common risks > **Note:** Resolving a platform identifier to a geometry requires exactly three things: the namespace it belongs to, the vintage of the boundary set it was minted against, and a crosswalk that performs that specific resolution. Skip any one of the three and the "geometry" produced is a guess, not a lookup — this is true even when the id itself never changes, because the boundary underneath it can move without the id being reissued. **Namespace ambiguity** is the most common failure: two vendors' "market 12" are different regions, and nothing about the string `"12"` signals which vendor's scheme is in force — ingesting an id without recording its source namespace makes it silently ambiguous the moment a second data source enters the pipeline. **Vintage mismatch** compounds this: even within one namespace, an id assigned against a 2019 boundary set and resolved against a 2024 crosswalk can resolve to a different polygon, because DMA and postal boundaries specifically are redrawn on cycles far shorter than most systems' assumed "this never changes" treatment of a region code. **Unmatched ids dropping silently** is the operational failure mode: a join between an id-keyed dataset and a crosswalk table that fails to match — because of a vintage gap, a namespace collision, or a simple typo — commonly drops the unmatched rows rather than raising an error, which understates whatever metric is being computed with no visible signal that anything went wrong. **Platform-native-ids-only constraints** are related but distinct: some platforms accept no polygon or coordinate input at all and require everything expressed as one of their own ids, making a versioned crosswalk to that namespace the only way in — one that must be maintained continuously as the platform revises its boundary definitions, on its own schedule, invisible to the caller unless the platform documents it. ## How it converts to H3 Because a platform identifier is a reference rather than a shape, there is no direct id-to-H3 polyfill — the conversion runs in the opposite direction, from H3 to the platform's id space, through a maintained crosswalk. See [H3 to platform-native geography](/docs/h3-to-platform-native-geography/) for how the crosswalk is built and versioned, how unmatched cells are reported rather than dropped, and how precision loss is quantified when a platform's id granularity is coarser than the H3 resolution expressed. Where the underlying region is available as an actual polygon, treat it as an [administrative boundary](/docs/administrative-boundaries/) instead and skip id resolution entirely — the polygon is strictly more information than any id that merely points at it. --- # Platform Target Count Constraints > Every execution platform imposes its own radius floors, target-count ceilings, and unsupported-geometry limits, and an H3 result set must be fitted to them, not assumed compatible. - **URL:** https://etherdata.ai/blog/geo-interop-kb/docs/platform-target-count-constraints - **Category:** platforms · **Exactness:** conservative - **Source geometry:** h3_cell_set - **Destination geometry:** platform_geo_id, point_radius - **Edge cases:** minimum-radius, radius-increments, platform-native-ids-only, unsupported-exclusions - **Related:** google-style-point-radius-execution, h3-to-platform-native-geography, h3-compaction-and-uncompaction --- platform-dependent ## Purpose A canonical H3 cell set is an interchange format; no advertising platform executes on H3 cells directly. Every platform imposes its own constraints on what a targetable unit can look like, and a conversion that ignores them produces a target list the platform will reject, silently truncate, or silently re-interpret. This page enumerates the recurring constraint classes and the mitigation for each, generically — see `data/platforms.yaml` for named platform-class instances of these limits. ## Source geometry and destination geometry Source geometry is an `h3_cell_set` at whatever resolution the canonical representation was built at. Destination geometry is whatever the target platform accepts: `platform_geo_id` (native regions), `point_radius` (circles), or occasionally a simplified polygon — never H3 cells themselves, since no mainstream ad platform has native H3 support. ## Exactness class Conservative by design: the fitting process in this page trades resolution and target-count for platform executability, and every mitigation below either coarsens the footprint or drops a request outright rather than silently exceeding a hard platform limit. ## The constraint classes | | | |---|---| | Minimum radius | A platform floor (commonly 1 km) below which a point-radius target cannot be executed. A fine H3 cell's inscribed or circumscribed radius can fall below this floor, making the cell un-executable as a circle. | | Maximum radius | A ceiling (commonly tens of km) above which a single circle cannot be drawn, forcing large contiguous areas to split into multiple circles. | | Radius increments | Platforms round a requested radius to a fixed increment (e.g. nearest 100 m), changing executed coverage from the requested value. | | Target-count limits | A hard cap on distinct targeting entries per line item (hundreds to tens of thousands), which a fine H3 polyfill of a large area can exceed by orders of magnitude. | | Unsupported polygons | Some platforms accept only circles and native IDs, forcing every polygon-derived target to be approximated by circles regardless of shape fidelity loss. | | Unsupported exclusions | A platform without exclusion support cannot express include-A-minus-B; the exclusion is rejected or silently dropped, executing on the full inclusion set. | | Platform-native IDs only | Some platforms accept only their own named geography IDs, requiring a maintained crosswalk from H3 with an accepted precision loss. | | Coordinate rounding | Platforms may round submitted circle-center coordinates on ingest, shifting the executed center by up to the rounding's implied distance. | | Undocumented deduplication | Some platforms silently merge or drop entries that overlap beyond an undocumented threshold, changing the executed count without warning. | | Optimized targeting expansion | Some platforms auto-widen a small-audience target to hit a delivery goal, expanding the footprint beyond the submitted geometry unless opted out. | | Reporting at a coarser level | Delivery and outcome reporting is often returned only at a coarser native geography (DMA, region), making the executed-vs-reported gap unmeasurable from reporting alone. | | Asynchronous boundary updates | A platform's native geography IDs can be redefined on the platform's own schedule, unsynchronized with the boundary vintage used to build the crosswalk. | ## Containment rule and boundary behavior Fitting an H3 cell set to a platform's constraints is, by construction, conservative-toward-executability, not conservative-toward-coverage: the process may coarsen resolution (increasing overreach) or drop unexecutable elements (reducing coverage) to satisfy a hard limit. Both outcomes must be reported — the fitted target list is not the same geography as the canonical H3 input, and the gap has a direction (coarsening overreaches; dropping underreaches) attributable to the constraint that forced it. ## Resolution behavior Resolution is the primary lever for fitting target-count limits: compacting adjacent same-value cells (`compact`) and, where insufficient, coarsening to a higher parent resolution reduces cell count at the cost of boundary precision. For minimum-radius floors, coarsening is again the lever — a coarser cell's circumscribed radius is larger and more likely to clear the floor — while for maximum-radius ceilings the opposite applies: a circumscribed circle exceeding the ceiling must split into smaller circles. ## Units and CRS EPSG:4326 for all geometry; radii in meters unless a platform's API documents feet or another unit, in which case the conversion must convert explicitly and record the platform's native unit in the `ConversionRecord`. ## Algorithm ```ts function fitToPlatform(cells: string[], platform: { minRadius: number | null; maxRadius: number | null; maxTargets: number | null; }) { let working = compact(cells); // Coarsen resolution stepwise until under maxTargets. while (platform.maxTargets && working.length > platform.maxTargets) { working = compact(coarsenOneLevel(working)); } // Fit each remaining cell's circumscribed circle to min/max radius. const circles = working.map((cell) => { const { radiusMeters, center } = circumscribedCircle(cell); const clamped = clampRadius(radiusMeters, platform.minRadius, platform.maxRadius); return { center, radiusMeters: clamped }; }); return circles; } ``` ## Parameters The target platform's declared `minRadius`, `maxRadius`, `radiusIncrements`, `maxTargets`, `polygonSupport`, `exclusionSupport`, `adminIdSupport`, and `nativeCellSupport` — all sourced from a platform capability record such as `data/platforms.yaml`, never assumed or hardcoded per campaign. ## Outputs A platform-executable target list (circles, native IDs, or a simplified polygon set) alongside a record of every coarsening step, dropped exclusion, and cell merged past its original boundary — the inputs needed to compute `overreach_ratio` and `coverage_ratio` against the canonical set. ## Quality metrics `overreach_ratio` and `coverage_ratio` against the pre-fitting canonical cell set, per the `platform_limit_optimized` profile ([conversion-profiles](/docs/conversion-profiles/)), whose stated guarantee is that the result respects `maxTargets` and `minRadius` for the named platform and whose stated tradeoff is that coarsening enlarges the effective footprint. ## Edge cases `minimum-radius` and `radius-increments` interact: a cell coarsened just enough to clear the minimum floor can still shift meaningfully once the platform rounds to its nearest increment, so the final check must run against the platform-quantized radius, not the pre-rounding one. `platform-native-ids-only` and `unsupported-exclusions` both require a fallback decided in advance — pre-subtracting an exclusion in cell space before crosswalking to native IDs is the standard mitigation, flagged as reported-vs-executed divergence regardless. ## Assumptions and limitations This page documents constraint classes generically; exact numeric limits must be sourced from the platform's current API documentation via a capability record, never from memory or this page — platform limits change without notice, and a stale assumption here is a `stale-boundaries`-class failure applied to platform capabilities rather than geographic ones. ## Illustration — the minimum-radius floor > Figure (min-radius-floor): An R9 inscribed circle (172 m, green) sits below a 400 m platform minimum (amber dashed): the cell cannot be executed as a circle at this resolution without coarsening. --- # Point To H3 > Point-to-cell containment is exact given a coordinate; the real subject of this page is how uncertain that coordinate usually is. - **URL:** https://etherdata.ai/blog/geo-interop-kb/docs/point-to-h3 - **Category:** source-to-h3 · **Exactness:** exact - **Source geometry:** poi, address, device_ping - **Destination geometry:** h3_cell_set - **Edge cases:** axis-order-reversal, rounded-coordinates, geocoding-uncertainty, duplicate-observations - **Related:** arbitrary-polygon-to-h3, point-radius-to-h3, coordinate-and-crs-failures, geometry-normalization --- exact ## Purpose Given a latitude/longitude pair, finding the H3 cell that contains it is a deterministic, exact geometric operation — the H3 grid partitions the sphere, so every point lies in exactly one cell at a given resolution (ignoring the measure-zero case of a point exactly on a cell boundary). What is not exact, and what this page is actually about, is the coordinate itself: POI locations, geocoded addresses, and device pings all carry uncertainty that the point-to-cell step inherits silently unless it is tracked explicitly. ## Source geometry and destination geometry Source geometry is one of `poi` (a point of interest with a claimed location), `address` (a mailing or street address prior to geocoding), or `device_ping` (a single observed lat/lng with a timestamp, typically from a mobile SDK or bid-stream signal). Destination geometry is a single H3 cell, or an `h3_cell_set` when a set of points is aggregated. ## Exactness class Point-to-cell containment is **exact**: `latLngToCell` returns the one cell whose boundary contains the given point, with no approximation in the containment test itself. This page's badge describes the *conversion step*, not the *input* — a device ping accurate to only 500 meters is still mapped to exactly one cell, but that cell may not be the cell the true device location would have produced. ## Containment rule and boundary behavior A point belongs to the unique cell whose polygon boundary contains it under H3's standard point-in-polygon test. Points falling exactly on a shared edge or vertex between cells are resolved by H3's internal tie-breaking rather than by any caller-visible rule; do not rely on which side a boundary point resolves to being stable across H3 library versions. This matters in practice for `device_ping` data snapped to a grid (see rounded coordinates, below) where a large share of points can land precisely on cell boundaries rather than being uniformly distributed within cells. ## Resolution behavior Resolution does not change whether the operation is exact — it changes how much a fixed amount of coordinate uncertainty matters. At res 6 (average cell edge length near 3.2 km), a 50-meter GPS error essentially never moves a point to a different cell. At res 10 (edge length near 65 m), the same 50-meter error frequently does. The right rule of thumb: choose a resolution whose cell diameter is large relative to the stated accuracy radius of the source, or explicitly report the probability the true point falls in a neighboring cell (via `gridDisk` around the assigned cell). ## Units and CRS Input must be EPSG:4326 decimal degrees, latitude first. Accuracy radius, where available, is reported in meters and should be treated as a 1-sigma or CEP50 radius depending on the source's own documentation — mobile SDKs and geocoders rarely agree on which, and mixing them without checking produces confidence estimates that are wrong by a factor of two or more. ## Algorithm ```ts const cell = latLngToCell(lat, lng, resolution); // Report neighbor cells the true point could plausibly occupy, // given a stated accuracy radius larger than the cell's own scale. const candidateNeighbors = gridDisk(cell, 1); ``` The same conversion with the Python bindings (`h3-py` v4): ```python cell = h3.latlng_to_cell(lat, lng, res) # Report neighbor cells the true point could plausibly occupy, # given a stated accuracy radius larger than the cell's own scale. candidate_neighbors = h3.grid_disk(cell, 1) # Cap the usable resolution to the coordinate's own precision: if lat/lng # were truncated (see rounded-coordinates below), h3.get_resolution(cell) # should not be trusted as more precise than the source data actually is. assigned_resolution = h3.get_resolution(cell) ``` The tested reference implementation for this conversion is the TypeScript in `lib/` (via `h3-js`); the Python above calls the equivalent `h3-py` functions directly, since point-to-cell containment has no library-specific logic to reproduce. ## Parameters Resolution, and — where the source provides it — an accuracy radius in meters used only for downstream uncertainty reporting, never to alter the containment result itself. ## Outputs `cell_id` at the stated resolution, the original coordinate pair for audit, and, when available, the source's own accuracy radius and observation timestamp carried through unchanged. ## Quality metrics Point conversions do not have a `coverage_ratio` in the polygon sense. Instead track: the fraction of points whose accuracy radius exceeds the chosen cell's edge length (a proxy for how often the "true" cell may differ from the assigned one), and the duplicate rate — the fraction of observations sharing an identical coordinate pair, timestamp, and source id. ## Edge cases Axis-order reversal is the most common silent failure: many formats list longitude first, and swapping lat/lng produces a coordinate that is often still a valid point on Earth — frequently landing in the ocean or another continent — with no error thrown. Always validate that latitude falls in negative-90 to 90 and longitude in negative-180 to 180, and where both are in range, cross-check against an expected bounding region rather than trusting field order. Rounded coordinates — device pings or POI feeds truncated to 2-3 decimal places for privacy or storage reasons — can introduce error of 100 meters to over 1 km depending on latitude, which is routinely larger than a res-9 or res-10 cell; treat any coordinate with suspiciously round decimal digits as lower-resolution input and cap the H3 resolution used for it accordingly, rather than polyfilling it as if it were survey-grade. Geocoding uncertainty applies to `address` inputs before they become points at all: rooftop, parcel-centroid, street-segment interpolation, and ZIP-centroid geocodes carry radically different implied accuracy (meters versus kilometers), and the geocode tier should be recorded alongside the resulting cell. Duplicate observations — the same device or POI reported multiple times at the same or near-identical coordinate within a short window — inflate density counts if not deduplicated by source id and timestamp window before aggregation into a cell-level count. ## Assumptions and limitations This conversion assumes the input coordinate is the caller's best available estimate of a real-world location; it does not attempt to detect or correct for measurement error, only to propagate accuracy metadata alongside the assigned cell where it exists. Stale POIs (a business location that closed or moved but remains in a feed) are a data-freshness problem, not a geometric one — no adjustment of resolution or containment rule corrects for a POI at the wrong address; that requires refreshing the source rather than tuning this conversion. ## Illustration — the point is exact, the location is not > Figure (point-accuracy): A 60 m accuracy radius at R12: the containing cell (cyan) is exact for the coordinate, but the accuracy disk touches many plausible cells (amber). --- # Point-Radius Geometries > A center coordinate plus a radius — the native execution unit for most DSPs and proximity products, and the geometry where the buffer method matters as much as the containment rule after it. - **URL:** https://etherdata.ai/blog/geo-interop-kb/docs/point-radius-geometries - **Category:** geometries - **Source geometry:** point_radius, device_ping - **Edge cases:** minimum-radius, radius-increments - **Related:** point-radius-to-h3, h3-to-inscribed-circle, h3-to-circumscribed-circle, points, arbitrary-polygons --- A point-radius geometry is a center coordinate plus a distance: not a polygon at rest, but a rule for constructing a disk on demand. It is the native execution unit of most demand-side platforms and proximity-targeting products precisely because it is cheap to specify and cheap to execute — "3 km around this point" is one comparison per candidate location, not a point-in-polygon test against an arbitrary boundary. That efficiency is also the family's limitation: a circle is rarely the true shape of the catchment it's standing in for, and the radius itself hides two decisions (geodesic vs. planar, and which unit) that change the executed area even when the stated number never changes. | | | |---|---| | Cardinality | One disk per center-plus-radius pair, constructed on demand, not stored | | Governed by | The platform or caller that defines the radius — no external authority | | Not a geometry | The radius number alone, before a buffer method (geodesic or planar) is chosen | | Converts via | Geodesic buffer to a disk polygon, then polyfill | ## Members | Member | What it represents | Typical source | |---|---|---| | Proximity targeting | "Within N km/mi of this point" ad or promo target | Platform targeting UI | | Store radius | A simplified stand-in for a store's real trade area | Manually chosen or platform default | | Device-accuracy disk | A device ping's positional uncertainty, expressed as a radius | GPS/network accuracy metadata | | Service radius | The area a business claims to serve | Manually declared | | Platform radius target | A DSP/ad-server's own point+radius execution primitive | Platform API parameter | ## Required metadata | Field | Why it's required | |---|---| | Radius units | Meters, feet, and miles are all in circulation; an unconverted unit produces an order-of-magnitude error | | Geodesic vs. planar | Determines whether the radius is measured as a great-circle distance or in a locally flat projection | | Center coordinate CRS | Almost always EPSG:4326, but must be confirmed for ingested platform exports | | Platform-declared minimum radius | Needed to know, before execution, whether the requested radius will be silently clamped up | ## Common risks **Geodesic vs. planar divergence**: a geodesic buffer measures the radius as a true great-circle distance from the center; a planar buffer applies it in a locally flat projection that is only accurate near that projection's reference latitude. At mid-latitudes and radii under 10 km the gap is usually under 1%, but it grows with both latitude and radius — a planar buffer at high latitude or over several kilometers can misstate coverage area by several percent, and the two methods should never be mixed within one campaign's targets. **Platform minimum-radius floors**: many platforms refuse radii below a threshold (500 m to 1 km is common) and silently clamp a smaller request up to the floor rather than rejecting it — a 200-meter catchment intended for conservative targeting can execute as a 1 km disk instead, and the requested-vs-executed radius should always be diffed rather than assumed equal. **Radius increments**: some platforms round the requested radius to a fixed step, changing the executed area in either direction depending on rounding rules. **Travel-time confusion**: a "10-minute drive" catchment is not a disk, and picking a radius that looks visually similar on a map discards the road-network shape that made the catchment meaningful in the first place — that case belongs to [arbitrary polygons](/docs/arbitrary-polygons/), not this family. > **Note:** Until it is buffered, a point-radius target has no geometry at all — just a number. Two systems agreeing on the same center and the same stated radius can still execute two different disks if one buffers geodesically and the other planar, or if one platform's minimum-radius floor silently overrides the smaller of the two requests. ## How it converts to H3 Point-radius targets are buffered into a geodesic (or, when matching platform behavior, planar) disk and then polyfilled under the same containment modes used for arbitrary polygons — see [point-radius to H3](/docs/point-radius-to-h3/) for the buffer-then-fill algorithm in both TypeScript and `h3-py`. The reverse direction — expressing an H3 cell back out as a platform-native point-radius target — is covered on [H3 to inscribed circle](/docs/h3-to-inscribed-circle/) (the largest disk guaranteed to stay inside the cell) and [H3 to circumscribed circle](/docs/h3-to-circumscribed-circle/) (the smallest disk guaranteed to cover it), which is where platform minimum-radius floors most often bite. --- # Point-Radius To H3 > Buffering a point into a geodesic disk before polyfilling, and why the buffer method matters as much as the containment rule that follows it. - **URL:** https://etherdata.ai/blog/geo-interop-kb/docs/point-radius-to-h3 - **Category:** source-to-h3 · **Exactness:** approximate - **Source geometry:** point_radius, device_ping - **Destination geometry:** h3_cell_set - **Edge cases:** minimum-radius - **Related:** arbitrary-polygon-to-h3, h3-to-inscribed-circle, point-to-h3, h3-to-equal-area-circle --- approximate ## Purpose Point-radius targets — "3 miles around this store," a device ping treated as a small catchment, a platform-defined default radius around a POI — are not polygons at the source, but they become one as the first step of this conversion. Everything after buffering is the same containment machinery as [arbitrary polygon to H3](/docs/arbitrary-polygon-to-h3/); this page is about getting the buffer itself right, since a wrong buffer poisons every mode downstream of it. ## Source geometry and destination geometry Source geometry is `point_radius` (a center coordinate plus a radius, however specified) or `device_ping` when a ping is being expanded into an uncertainty disk rather than treated as an exact point. Destination geometry is an `h3_cell_set`, or a weighted crosswalk if the disk is later intersected against multiple regions. ## Exactness class Approximate at two layered points: the disk itself is an approximation of whatever the "true" catchment shape is (real trade areas are rarely circular), and the polyfill of that disk inherits the approximation of whichever containment mode is chosen. ## Containment rule and boundary behavior The buffer step and the containment step are independent decisions: 1. **Buffer**: construct a geodesic disk of the stated radius around the center point. 2. **Containment**: polyfill that disk under one of the four modes — `center`, `full`, `intersect`, or `threshold` — exactly as documented on the [arbitrary polygon](/docs/arbitrary-polygon-to-h3/) page. The buffer itself has a boundary-behavior choice that predates polyfilling: geodesic versus planar radius. A **geodesic** buffer measures the radius as a great-circle distance from the center, correctly accounting for Earth's curvature; a **planar** buffer applies the radius in a locally flat projection and is only accurate near the projection's reference latitude. At mid-latitudes over radii under 10 km the discrepancy is usually under 1%, but it grows with both latitude and radius, and a planar buffer applied at high latitude or over a multi-kilometer radius can miscalculate coverage area by several percent — always default to geodesic unless a platform explicitly executes planar circles, in which case the planar buffer should be used to match what will actually be delivered, not what is geometrically correct. > Figure (point-radius): A 500 m disk buffered from a point, filled at R10 (intersect): 67 cells. ## Resolution behavior As with any polyfilled disk, higher resolution tightens the gap between the polyfilled cell set and the true disk area for whichever containment mode is used. Small radii interact badly with coarse resolutions: a 500-meter radius disk polyfilled at res 6 (edge length ~3.2 km) may resolve to a single cell under `center` regardless of where within that cell the true center falls, which defeats the purpose of specifying a radius at all — resolution should be chosen so the disk diameter spans at least several cells. ## Units and CRS Center coordinate in EPSG:4326; radius in meters, converted from any source unit (miles, feet, drive-time-derived meters) before buffering. Geodesic buffering computes distance as haversine (or a more precise geodesic formula for large radii) rather than planar Euclidean distance in degrees, which is not a distance unit at all and varies with latitude. ## Algorithm ```ts // Geodesic disk buffer, then polyfill under the chosen containment mode function pointRadiusToH3(center, radiusMeters, resolution, mode) { const disk = geodesicBuffer(center, radiusMeters); // polygon approximation return polygonToH3(disk, { resolution, mode }); } const cells = pointRadiusToH3(storeLocation, 4828, 9, "intersect"); // 3 mi ``` The same conversion with the Python bindings (`h3-py` v4): ```python from shapely.geometry import Point, Polygon from shapely.ops import transform def geodesic_disk(center_lat, center_lng, radius_m, vertices=64): # Geodesic buffer: use an azimuthal-equidistant projection centered on # the point so a planar buffer of radius_m is exact at that center, # then reproject the ring back to lat/lng. A naive buffer in raw # lat/lng degrees is a planar (not geodesic) approximation and drifts # with latitude and radius, per the note above. aeqd = pyproj.CRS.from_proj4( f"+proj=aeqd +lat_0={center_lat} +lon_0={center_lng} +units=m" ) to_aeqd = pyproj.Transformer.from_crs("EPSG:4326", aeqd, always_xy=True).transform to_wgs84 = pyproj.Transformer.from_crs(aeqd, "EPSG:4326", always_xy=True).transform disk_m = Point(0, 0).buffer(radius_m, quad_segs=vertices // 4) disk_deg = transform(to_wgs84, disk_m) # back to (lng, lat) in EPSG:4326 return [(lat, lng) for lng, lat in disk_deg.exterior.coords] # h3-py wants (lat, lng) def point_radius_to_h3(center_lat, center_lng, radius_m, res): ring = geodesic_disk(center_lat, center_lng, radius_m) poly = h3.LatLngPoly(ring) return h3.polygon_to_cells(poly, res) # center containment only, see note cells = point_radius_to_h3(store_lat, store_lng, 4828, 9) # ~3 mi ``` `h3.polygon_to_cells` is center-containment only; reproducing `full`, `intersect`, or `threshold` on the disk requires the same per-cell `shapely` area classification shown on the [arbitrary polygon](/docs/arbitrary-polygon-to-h3/) page. The tested reference implementation for this conversion is the TypeScript in `lib/`. ## Parameters Center coordinate, radius (meters, after unit conversion), buffer method (`geodesic` or `planar`), containment mode, resolution, and vertex count used to approximate the disk polygon (more vertices reduce polygonal approximation error at the cost of polyfill time). ## Outputs An `h3_cell_set` at the stated resolution and mode, or a weighted crosswalk of `(cell_id, disk_id, intersection_area, cell_coverage_fraction)` rows when the disk is being intersected against multiple downstream regions rather than consumed on its own. ## Quality metrics `coverage_ratio`, `overreach_ratio`, and `jaccard` computed against the buffered disk polygon (not the polyfilled cell set against itself) — this isolates polyfill error from buffer error. Report the two error sources separately: disk-polygon-vertex-count error (how well the disk approximates a true circle) and containment-mode error (how well the cell set approximates the disk). ## Edge cases Travel-time catchments are frequently confused with point-radius: a "10-minute drive" catchment is not a disk at all, and forcing it through this conversion by picking an equivalent-looking radius silently discards the road-network shape that made the catchment meaningful — that case belongs to a network-distance corridor, not a geodesic buffer. Platform-defined radii (a DSP that only accepts point+radius targeting and enforces its own minimum, e.g. 1 km) mean the caller's intended radius and the executed radius can differ; always record both the requested and the platform-clamped radius. Minimum-radius constraints are the sharpest edge case: many platforms refuse radii below a floor (500 m to 1 km is common), silently clamping a smaller request up to the floor rather than erroring — a store-specific 200-meter catchment intended for `full`-mode conservatism can be executed as a 1-kilometer disk instead, changing `overreach_ratio` from near zero to substantial. Always diff the requested radius against the platform's documented minimum before treating the executed geometry as a faithful representation of the request, and see [inscribed-circle](/docs/h3-to-inscribed-circle/) for the reverse direction of this problem, where an H3 cell is expressed as a platform-native point-radius target. ## Assumptions and limitations This conversion assumes the radius is a genuine Euclidean (geodesic) distance from a single center point; it is the wrong tool for irregular or directional catchments, and should not be dressed up as one by picking a radius that merely looks visually similar on a map. It also assumes the buffer step runs before any containment decision — polyfilling a raw point with a "radius" parameter passed into a polygon mode without an actual buffered polygon is not this conversion and produces undefined results. --- # Points > A single coordinate that references a place rather than describing an extent — POIs, addresses, devices, and the uncertainty each one silently carries. - **URL:** https://etherdata.ai/blog/geo-interop-kb/docs/points - **Category:** geometries - **Source geometry:** poi, address, device_ping - **Edge cases:** axis-order-reversal, rounded-coordinates, ip-derived-location, consent-precision, duplicate-observations - **Related:** point-to-h3, coordinate-and-crs-failures, multipoints, point-radius-geometries --- A point is a single `(lat, lng)` coordinate that stands in for a place, an event, or a moment, but never describes an extent of its own. That distinction matters more than it looks: a point is frequently treated as if it has no error — as if a store really does sit at exactly `40.7128, -74.0060` — when every member of this family carries some positional uncertainty that a downstream conversion needs to be told about explicitly rather than allowed to assume away. | | | |---|---| | Cardinality | One coordinate per observation — no extent | | Governed by | Whatever produced the coordinate: a GPS chip, a geocoder, a manual placement | | Not a geometry | An address string, before a geocoder resolves it to a coordinate | | Converts via | Direct latLngToCell at the chosen resolution | ## Members | Member | What the point represents | Typical uncertainty source | |---|---|---| | POI / store point | A place's canonical location | Rooftop vs. building-centroid placement | | Address | A postal address, pre-geocoding | Geocoder match confidence | | Venue entrance | A single entry point on a larger footprint | Which entrance was digitized, and when | | Device ping | A device's reported coordinate | GPS/network accuracy radius | | Impression | An ad-serving bid or render event's location field | Bidstream truncation, IP fallback | | Conversion | A purchase or app event's location field | Attribution-window location capture method | | Transaction | A point-of-sale record's location | Store location, not customer location | | Sensor reading | A fixed or mobile sensor's coordinate | Installation survey accuracy | | Event | A single logged occurrence | Whatever produced the coordinate upstream | ## Required metadata | Field | Why it's required | |---|---| | CRS | Nearly always EPSG:4326, but never assume — verify, especially for ingested vendor feeds | | Source | Geocoded, device-reported, or manually placed — each carries a different error profile | | Timestamp | A point is a snapshot; without a timestamp, staleness can't be assessed | | Accuracy radius (device pings) | Converts a bare coordinate into an honest uncertainty disk instead of a false-precision point | | Consent state (device pings) | Determines the resolution the point may legally or contractually be used at | | Geocoder + match confidence (addresses) | A low-confidence geocode should not be treated with the same trust as a rooftop match | > **Note:** An address is text. It becomes a member of this family only after a geocoder resolves it to a coordinate — and that resolution is itself an approximation, ranging from a precise rooftop match to a ZIP-centroid fallback with no street-level information at all. Carrying the match confidence forward is what keeps a bad geocode from being consumed as if it were as trustworthy as a surveyed POI. ## Common risks **Geocoding uncertainty** is the largest source of error for addresses and low-quality POI feeds: a rooftop match and a street-centroid or ZIP-centroid fallback can differ by tens to thousands of meters, and the geocoder's confidence score is the only signal that distinguishes them. **Axis-order reversal** — a coordinate supplied as `[lat, lng]` where `[lng, lat]` is expected, or vice versa — silently places a point in the wrong hemisphere when both values happen to fall in valid range; range-checking and swap-detection should reject ambiguous cases rather than guess. **Rounding and truncation** (bidstream coordinates rounded to 2–3 decimal places) snap points to a coarse grid that biases which cell they land in, especially at high H3 resolutions where the rounding grid is coarser than the cell itself. **IP-derived location** is coarse and centroid-biased by construction — it is not physical presence and must be labeled with the correct matching semantic, not treated as a device fix (see [advertising-geographic matching semantics](/docs/advertising-geographic-matching-semantics/)). **Staleness** affects POIs specifically: a store point surveyed years ago may no longer reflect a relocated or closed location. **Duplicates** — the same event logged more than once — inflate counts once points are aggregated; see [multipoints](/docs/multipoints/) for the aggregate case. ## How it converts to H3 A single point converts to H3 with `latLngToCell` at the chosen resolution — conceptually the simplest conversion in this knowledge base, and for that reason the one where skipped metadata (accuracy, consent, geocoder confidence) does the most silent damage. See [point to H3](/docs/point-to-h3/) for the full treatment, including how accuracy radius should be handled when a point is really a device fix rather than a surveyed location, and [point-radius geometries](/docs/point-radius-geometries/) for when a point is deliberately expanded into a disk before conversion. --- # Privacy And Minimum Aggregation > Small-cell re-identification and device-trajectory exposure are geometry problems as much as policy problems, and the mitigations are enforceable at the conversion layer. - **URL:** https://etherdata.ai/blog/geo-interop-kb/docs/privacy-and-minimum-aggregation - **Category:** privacy - **Source geometry:** device_ping, trajectory, h3_cell_set - **Destination geometry:** h3_cell_set, multipoint_audience - **Edge cases:** sparse-audience-suppression, consent-precision, device-trajectory-exposure - **Related:** advertising-geographic-matching-semantics, resolution-selection, conversion-profiles --- privacy-safe ## Why aggregation, not geometry, is the privacy boundary A polygon or an H3 cell is not private or unsafe by itself; the risk is a function of how many distinct people or devices are represented inside it. A precisely drawn, geometrically exact 50 m cell around a single-family home is a privacy failure at any exactness level, while a coarse, approximate 5 km cell over a stadium during an event is not. Every conversion in this knowledge base that terminates in an audience count or a device set must therefore carry a minimum aggregation rule alongside its containment rule — the two are independent constraints and a page that documents one without the other is incomplete. ## Small-cell re-identification A cell reporting an audience count of one to a small number of digits allows an observer with any side information (a home address, a workplace, a schedule) to re-identify the specific person or household the count refers to. This risk exists at every resolution: a coarse cell with a sparse population (a rural R5 cell with three households) is exactly as exposed as a fine cell in a dense city, because the risk is driven by population count inside the cell, not cell area. > **Note:** Do not use H3 resolution as a stand-in for privacy safety. A resolution floor without a population/audience floor still permits small-cell exposure in sparse geographies; a population floor without a resolution floor still permits gerrymandered slivers drawn to isolate one household. Both constraints must be enforced together. ## k-anonymity thresholds and sparse-audience suppression The standard mitigation is a k-anonymity threshold: no reported cell may represent fewer than k distinct people or devices, for a k set by policy (commonly 5–50 depending on jurisdiction and data sensitivity). Enforcement has two mechanisms, and most production systems use both: - **Suppression** — drop cells below the threshold from the output entirely, accepting a coverage gap. - **Roll-up** — merge a below-threshold cell with its parent (coarser resolution) or with adjacent cells until the merged population clears k, accepting a resolution loss for that region only. `sparse-audience-suppression` is the general term for both mechanisms. Suppression is preferred when a coverage gap is disclosable and acceptable; roll-up is preferred when a caller needs a value for every requested cell and can tolerate uneven resolution across the response. ## Differential privacy and temporal leakage Threshold suppression alone is defeated by repeated queries: an attacker who queries the same region across multiple time windows, or across overlapping cell sets, can reconstruct a below-threshold value by subtraction (query region A, query region A plus one household, take the difference). Differential privacy mitigates this by adding calibrated noise to every released count, bounding the information any single query — or any combination of queries — can reveal about one individual, regardless of how many times the same underlying population is queried. Threshold suppression without noise is a necessary but not sufficient control; a system exposed to repeated or overlapping queries needs differential privacy or an equivalent query-budget mechanism on top of it. Temporal leakage is the time-axis version of the same problem: a cell that clears k-anonymity when aggregated over a month can fall below k when sliced to a single hour of a single day. The **minimum aggregation window** is the smallest time bucket at which the population/audience floor still holds, and it must be enforced as a floor on query granularity, not just on spatial resolution. ## Household-level targeting risk and device trajectory exposure Home-location and household-graph targeting (see [advertising-geographic-matching-semantics](/docs/advertising-geographic-matching-semantics/)) concentrates risk because the geometry — a single parcel or a fine H3 cell — is drawn specifically to isolate one household by construction, not as a side effect of a coarse aggregation. This is a distinct risk class from cell-count suppression and requires a minimum-parcel or minimum-radius floor independent of any population threshold. Device trajectories carry a related but separate risk: an ordered sequence of high-resolution location points is frequently uniquely identifying even when no single point in the sequence would be, because the sequence itself (home, then a specific workplace, then a specific gym, in that order) is a fingerprint. The mitigation is not point-level suppression but sequence-level treatment — dropping temporal order, aggregating to origin-destination pairs without intermediate points, or coarsening both the spatial and temporal grain of the trajectory before it is stored or joined to any other dataset. ## Resolution degradation and regional restrictions Consent state and jurisdiction both function as external inputs to the aggregation floor, not as geometry inputs: a user who has not granted precise-location consent must have their location coarsened to the platform's declared reduced-precision tier before any cell assignment, and a region under a stricter regional privacy regime may carry a higher k or a coarser minimum resolution than the platform's global default. Both must be applied before polyfilling, never after — coarsening a value after it has already been assigned to a fine cell does not undo the exposure that fine assignment already created if the fine-grained intermediate was persisted or logged anywhere in the pipeline. ## The privacy_safe profile The `privacy_safe` conversion profile ([conversion-profiles](/docs/conversion-profiles/)) codifies these rules as defaults: `center` containment (a single deterministic assignment per device, avoiding fractional double-counting across cells), a resolution policy that enforces both a minimum physical cell size and a minimum audience threshold, and suppression of any cell that does not clear the configured threshold after both are applied. Its stated guarantee is that no cell is reported below the configured audience/area threshold; its stated tradeoff is that resolution degradation and suppression reduce granularity in exactly the sparse regions where a naive system would be most exposed. Any pipeline reporting `physical_presence` or `home_location` audiences at H3 resolution 8 or finer without an explicit reference to this profile should be treated as unaudited. --- # Raster To H3 > Choosing the correct aggregation statistic when resampling a gridded raster into H3 cells, and why the wrong choice manufactures false precision. - **URL:** https://etherdata.ai/blog/geo-interop-kb/docs/raster-to-h3 - **Category:** source-to-h3 · **Exactness:** approximate - **Source geometry:** raster - **Destination geometry:** h3_cell_set - **Edge cases:** resolution-mismatch, nodata-values, coastal-pixels - **Related:** arbitrary-polygon-to-h3, conversion-quality-metrics, geometry-normalization --- approximate ## Purpose Rasters — land cover, elevation, imagery-derived classifications, gridded population estimates — are regular pixel grids, not vector geometry, so converting one to H3 is a resampling problem: each H3 cell must be assigned a value derived from the (usually several) pixels it overlaps. Which aggregation statistic is correct depends entirely on what the raster's values mean — a mean is correct for a continuous field and wrong for a categorical one, and no single default statistic is safe across raster types. ## Source geometry and destination geometry Source geometry is `raster`: a regular grid of pixels, each with one or more band values, a defined CRS, and a resolution (pixel size) that is usually fixed but occasionally coarser than the target H3 resolution. Destination geometry is an `h3_cell_set` where every cell carries one or more derived attribute values plus the aggregation method used to produce them. ## Exactness class Approximate: a cell's assigned value is always a summary of multiple pixel values (or an extrapolation from a single covering pixel), never a measurement made at the cell's own scale. ## Containment rule and boundary behavior — aggregation statistics The "containment rule" for raster conversion is which pixels count toward a cell's value and how they are combined: | | | |---|---| | Center sample | Value of the pixel containing the cell's center. Cheapest; can miss small features entirely. | | Nearest | Value of the pixel whose center is nearest the cell's center; steadier than center-sample when grids are offset. | | Mean | Area-weighted or simple average of overlapping pixels. Correct only for continuous, additive fields — meaningless for category codes. | | Median | Middle value of overlapping pixels. More outlier-robust than mean for continuous fields; still meaningless for categories. | | Min / max | Extremum of overlapping pixels. Used for conservative or worst-case summaries, not central tendency. | | Sum | Total of overlapping pixel values, for count- or density-type rasters meant to be additive (e.g. population per pixel). | | Majority | Most frequent category among overlapping pixels. The correct default for categorical rasters (land cover, zoning). | | Fractional category | Per-category area share among overlapping pixels. Retains what majority discards — a 60/40 forest/water cell is not honestly 'forest'. | | Area-weighted | Any statistic above weighted by each pixel's actual overlap area rather than counted whole. Required once pixel size approaches cell size. | | Confidence-weighted | Aggregation weighted by a per-pixel confidence/quality band, where the raster ships one. Down-weights low-confidence pixels. | > Figure (raster-h3): A synthetic smooth field aggregated to a per-cell mean at R8. ## Resolution behavior The relationship between pixel size and H3 cell size determines which statistics are even meaningful. When cells are much larger than pixels (coarse H3 resolution over fine imagery), mean, median, majority, and fractional-category are all well-supported by many pixels per cell. When cells are smaller than or comparable to pixels (fine H3 resolution over coarse raster data — the common case for climate or population grids), every statistic collapses toward center-sample or nearest, because a single pixel dominates or exactly covers the cell, and no aggregation actually occurs; reporting a "mean" over one pixel is not wrong but implies a precision the data does not have. ## Units and CRS Rasters frequently ship in a projected CRS (UTM zones, Albers Equal Area, Web Mercator) that must be reprojected to EPSG:4326 — or, better, have the overlap computed in the raster's native equal-area projection if one is used, since equal-area projections keep pixel-area weighting accurate, while reprojecting to EPSG:4326 first and then area-weighting in unprojected degrees is a common source of quiet error. State pixel size in meters at the raster's stated resolution, noting that "meters per pixel" for a lat/lon raster varies with latitude unless the raster is already in an equal-area or equidistant projection. ## Algorithm ```ts // Area-weighted mean for a continuous raster band function rasterCellValue(cellBoundary, rasterBand, stat = "area-weighted-mean") { const overlappingPixels = rasterBand.pixelsOverlapping(cellBoundary); switch (stat) { case "area-weighted-mean": return weightedAverage( overlappingPixels.map((p) => p.value), overlappingPixels.map((p) => p.overlapAreaM2(cellBoundary)), ); case "majority": return modeByOverlapArea(overlappingPixels, cellBoundary); case "fractional-category": return fractionalCoverageByCategory(overlappingPixels, cellBoundary); default: throw new Error(`unsupported stat: ${stat}`); } } ``` The same conversion with the Python bindings (`h3-py` v4): ```python from shapely.geometry import Point, Polygon def raster_cell_value(cell, raster_band, transform, stat="mean"): # Sample the pixels whose centers fall within the cell's boundary, # then aggregate them per the chosen statistic. h3-py has no raster # helper, so the sampling loop is written directly against the boundary. boundary = Polygon([(lng, lat) for lat, lng in h3.cell_to_boundary(cell)]) minx, miny, maxx, maxy = boundary.bounds col_min, row_min = ~transform * (minx, maxy) col_max, row_max = ~transform * (maxx, miny) values, weights = [], [] for row in range(int(row_min), int(row_max) + 1): for col in range(int(col_min), int(col_max) + 1): px, py = transform * (col + 0.5, row + 0.5) # pixel center if boundary.contains(Point(px, py)): values.append(raster_band[row, col]) weights.append(1.0) # swap for pixel-overlap area if area-weighting if not values: return None if stat == "mean": return float(np.average(values, weights=weights)) if stat == "majority": vals, counts = np.unique(values, return_counts=True) return vals[np.argmax(counts)] raise ValueError(f"unsupported stat: {stat}") cells = h3.polygon_to_cells(h3.LatLngPoly(aoi_ring), res=8) per_cell_mean = {c: raster_cell_value(c, band, raster_transform) for c in cells} ``` `h3.cell_to_boundary` gives the polygon to sample against; per-pixel overlap-area weighting (rather than the point-count weighting shown above) requires clipping each pixel's own footprint against the boundary with `shapely`, exactly as the area-weighted mean does in the TypeScript. The tested reference implementation for this conversion is the TypeScript in `lib/`. ## Parameters Aggregation statistic (must match the raster's measurement scale — categorical versus continuous), H3 resolution, whether area weighting is applied, and which band(s) to aggregate for multi-band rasters. ## Outputs An `h3_cell_set` with one or more attribute values per cell, the aggregation statistic used, the source raster's native resolution and CRS, and — for fractional-category output — a nested distribution rather than a single scalar per cell. ## Quality metrics Report the pixel-to-cell area ratio (source raster resolution versus H3 cell area) as the primary diagnostic: a ratio far from 1 signals either over-aggregation (many pixels compressed into one statistic, losing variance) or under-aggregation (one pixel stretched across many cells, manufacturing false spatial precision). For categorical rasters, report the majority statistic's own confidence — the winning category's share of overlapping-pixel area — since a 34 percent plurality reported as "the" land cover is materially weaker evidence than a 90 percent majority. ## Edge cases Resolution mismatch is the central failure mode: choosing an H3 resolution finer than the raster's native pixel size does not create information, it interpolates it — every cell within a single source pixel reports an identical value with a false appearance of cell-level precision. Always cap the usable H3 resolution to where pixel-to-cell ratio stays well above 1. Nodata values (masked pixels, flagged by a sentinel like -9999 or a separate mask band) must be excluded from every statistic explicitly; averaging a sentinel into a mean silently corrupts any cell touching a masked pixel, common at tile edges and over water in land-only datasets. Coastal pixels compound this: many environmental and demographic rasters mask ocean as nodata, so a shoreline cell can have most of its overlapping pixels excluded, and naive aggregation either extrapolates the land value across the whole cell or wrongly suppresses a cell that is mostly valid land. Area-weighted aggregation over only the valid pixels, with the valid-area fraction reported alongside the value, is the correct treatment. ## Assumptions and limitations This conversion assumes the raster's value semantics (categorical versus continuous, additive versus rate) are known before an aggregation statistic is chosen; there is no statistic that is safe to apply by default across raster types, and applying mean to a categorical raster or majority to a continuous one produces a value that is syntactically valid and substantively meaningless. It also assumes the raster's stated resolution and CRS metadata are accurate — an unlabeled or mislabeled raster should be inspected before conversion, since resolution-mismatch handling depends entirely on knowing the true pixel size. --- # Rasters > Gridded fields — population, elevation, weather, land use, imagery, audience-density surfaces — and the resolution-mismatch problems that surface the moment a fixed pixel grid meets a hexagonal cell grid. - **URL:** https://etherdata.ai/blog/geo-interop-kb/docs/rasters - **Category:** geometries - **Source geometry:** raster - **Edge cases:** resolution-mismatch, nodata-values, coastal-pixels - **Related:** raster-to-h3, arbitrary-polygons, conversion-quality-metrics --- A raster is a gridded field: a value sampled on a regular array of pixels covering some extent, rather than a discrete shape with a boundary. This makes rasters structurally different from every other family in this catalogue — there is no polygon to polyfill, only a continuous surface that must be resampled onto the H3 grid, and the two grids (fixed-size square pixels, roughly-equal-area hexagonal cells) never align exactly. Every raster-to-H3 conversion is therefore a resampling problem before it is anything else, and the resampling method chosen changes the answer as much as the source data does. | | | |---|---| | Cardinality | A continuous surface sampled on a fixed pixel grid, not a discrete shape | | Governed by | Whichever agency or model produced the surface (WorldPop, NOAA, a vendor model) | | Not a geometry | A band index or land-cover class code — the surface is the pixel grid itself | | Converts via | Resampling (nearest, bilinear, or area-weighted) per cell, not polyfilling | ## Members | Member | What the surface represents | Typical format | |---|---|---| | Population | Gridded population count or density | GeoTIFF (e.g. WorldPop) | | Elevation | Digital elevation model | GeoTIFF (DEM) | | Weather | Temperature, precipitation, wind fields | NetCDF, GRIB | | Pollution | Air-quality index or pollutant concentration | GeoTIFF, NetCDF | | Land use / land cover | Classified land-cover category per pixel | GeoTIFF (categorical) | | Flood depth | Modeled inundation depth | GeoTIFF | | Satellite imagery | Multispectral or RGB reflectance | GeoTIFF, COG | | Signal strength | Cellular or wireless coverage estimate | GeoTIFF, proprietary grid | | Audience-density surfaces | Modeled population or audience concentration | GeoTIFF, proprietary grid | ## Required metadata | Field | Why it's required | |---|---| | CRS | Rasters are frequently delivered in a projected CRS (UTM, Albers) and must be reprojected before cell alignment | | Native pixel resolution | Determines whether the raster is finer or coarser than the target H3 resolution, which dictates the correct aggregation method | | Declared no-data sentinel | A raster's "no data" value (commonly `-9999` or similar) must be masked, not averaged in as if it were a real reading | | Band semantics | What each band represents and its units — a raster with unlabeled bands cannot be aggregated correctly regardless of resolution | ## Common risks **Resolution mismatch** cuts both ways: a coarse raster (say, 1 km population pixels) sampled onto fine H3 cells (res 9, ~0.1 km²) produces false precision — many adjacent cells reporting different values that are really the same interpolated or repeated pixel value, implying an accuracy the source data never had. A fine raster (10 m imagery) aggregated onto coarse cells (res 6) needs area-weighted aggregation across every pixel the cell covers; averaging without area weighting biases the result toward whichever pixels happen to be enumerated first. **Unmasked no-data pixels**: if the sentinel value isn't filtered before aggregation, it gets averaged in as if it were a real reading, dragging every statistic (mean, sum, density) in the sentinel's direction — a `-9999` no-data pixel included in a mean computation produces a wildly wrong, silently plausible number. **Coastal and mixed pixels**: pixels straddling land and water, or straddling two land-cover classes, cannot be cleanly labeled at the pixel level; forcing a single per-cell category onto a boundary cell instead of reporting a land-fraction or class-mixture confidence discards real uncertainty as false certainty. **False precision generally**: any raster aggregate reported without a coverage-fraction or valid-pixel-count alongside it invites readers to trust a number more than the underlying grid supports. ## How it converts to H3 Rasters convert by sampling or aggregating pixel values per cell — nearest, bilinear, or area-weighted-mean depending on the resolution relationship between pixel and cell — documented on [raster to H3](/docs/raster-to-h3/), including the specific area-weighting algorithm and how to carry a coverage-fraction and valid-pixel-count forward per cell so downstream consumers can see how much of a raster aggregate is real signal versus interpolation. A raster is never converted by extracting contours and polyfilling them as an [arbitrary polygon](/docs/arbitrary-polygons/) unless the goal is specifically a categorical boundary (e.g. a flood-extent polygon) derived from a threshold on the surface — that is a distinct, lossier operation from full-surface resampling and should be labeled as such. --- # Requested Vs Executed Geography > A campaign geography passes through six distinct forms between the ask and the outcome, and most reporting disputes trace back to two of those forms being silently conflated. - **URL:** https://etherdata.ai/blog/geo-interop-kb/docs/requested-vs-executed-geography - **Category:** concepts - **Edge cases:** unsupported-exclusions, stale-boundaries - **Related:** geographic-interoperability-model, conversion-quality-metrics, inclusion-and-exclusion-semantics --- "We targeted the county" is a sentence that hides at least four decisions: what geometry represented the county, at what H3 resolution and containment rule it was polyfilled, what shape a delivery platform actually executed, and what geography the delivery report and the outcome attribution used to count results. Each of those is a distinct object. Treating them as one object — "the geography" — is why coverage numbers, delivered-impression counts, and lift estimates disagree without an obvious cause. ## Six named forms | | | |---|---| | Requested | The buyer's ask, in the buyer's vocabulary: a DMA id, a 3-mile ring around a store, a named trade area. Frequently an identifier, not a geometry — see the geometry catalogue. | | Source | The concrete geometry supplied to stand in for the request: a shapefile ring, a GeoJSON polygon, a list of store points with radii. Already a choice, and already lossy relative to the request. | | Normalized | The source geometry after CRS detection, reprojection to EPSG:4326, ring closure, winding correction, and self-intersection flagging. See geometry normalization. | | Canonical H3 | A set of H3 cells at a stated resolution under a stated containment rule (center, full, intersect, or threshold). The interchange form every downstream conversion starts from. | | Executed | What actually runs on a platform: point+radius circles, a simplified polygon, or a crosswalked platform-native id. An approximation of the canonical H3 set, never identical to it. | | Reported / attributed | The geography a delivery report or an outcome-attribution join actually uses — often coarser than execution, and sometimes a different geometry family entirely. | ## Lifecycle ```mermaid flowchart LR Req["Requested"] --> Src["Source"] Src --> Norm["Normalized"] Norm --> H3c["Canonical H3"] H3c --> Exec["Executed"] Exec --> Rep["Reported"] Rep --> Attr["Attributed"] ``` Every arrow is a documented conversion elsewhere in this knowledge base, and every arrow can change the geography's extent, resolution, or family. The model exists so a claim about any one stage can be checked against the adjacent stages rather than assumed to equal them. ## Six concrete divergences **(a) An H3 cell executed as an outer circle.** A DSP that only accepts point+radius targets receives the [circumscribed circle](/docs/h3-to-circumscribed-circle/) of each canonical cell. The circumscribed disk strictly contains the cell — every point of the cell lies inside the disk — but it also covers ground outside the cell, and adjacent cells' circles overlap each other. The executed footprint is measurably larger than, and self-overlapping relative to, the canonical H3 set. Reporting "we targeted the cell" without stating the circle mode hides both the overreach and the double-eligibility. This page has no `ts` algorithm block of its own — the conversions it narrates are each documented (and coded) on their own page. A short illustration with the Python bindings (`h3-py` v4) of just case (a), turning a requested cell set into executed circles: ```python def cell_to_circumscribed_circle(cell: str) -> tuple[tuple[float, float], float]: center = h3.cell_to_latlng(cell) # Radius = greatest great-circle distance from center to any boundary # vertex — the smallest circle that still fully contains the cell. radius = max( h3.great_circle_distance(center, vertex, unit="m") for vertex in h3.cell_to_boundary(cell) ) return center, radius requested_cells = h3.grid_disk("872830829ffffff", 1) executed_circles = [cell_to_circumscribed_circle(c) for c in requested_cells] # requested_cells: 7 discrete, non-overlapping hexagons. # executed_circles: 7 overlapping disks whose union is strictly larger than # the hexagons' union — requested != executed. ``` The tested reference implementation in this knowledge base is the TypeScript `circumscribedCircle` in `lib/h3/circles`, which derives the radius from a densified boundary (denser sampling near pentagons and face-crossing cells) rather than the six raw vertices used above for illustration. > Figure (circles-overlap): H3 cells requested, but executed as circumscribed circles: the executed footprint overlaps and exceeds the cells. **(b) A DMA polyfilled into H3.** A Nielsen DMA polygon center-polyfilled at resolution 7 drops boundary cells whose centers fall just outside the DMA line, and — because DMA lines rarely align with H3 cell edges — a center-contained cell set will disagree with the DMA polygon along its entire perimeter, not just at a few points. The canonical H3 set is a genuine partition of the H3 grid, but it is not the DMA; it is the DMA as seen through one specific containment rule at one specific resolution. **(c) H3 mapped back to postal codes.** Crosswalking the canonical cell set to ZIP codes for a platform that only accepts postal targeting introduces a second lossy hop: ZIP codes are USPS delivery routes, not polygons ([an identifier is not a geometry](/docs/geometry-catalogue/)), so the "ZIP polygon" used is itself a third-party ZCTA approximation. Two independent approximation errors compound — H3-to-DMA, then DMA-cells-to-ZIP — and neither is visible in a report that just says "targeted by ZIP." **(d) An exclusion the platform cannot express.** The request is "include the metro, exclude the stadium." If the platform lacks exclusion support, the correct engineering response is to pre-subtract in cell space — `effectiveGeography(inclusions, exclusions, res)` — and target only the difference; the wrong response is to submit the inclusion alone and let the exclusion silently vanish. See [unsupported exclusions](/docs/inclusion-and-exclusion-semantics/): the reported geography and the executed geography will match in that failure mode, which is precisely what makes it dangerous — nothing downstream flags a mismatch, because the platform faithfully executed what it was given. **(e) Reporting only at state level.** Execution ran at resolution 8 circles; the platform's reporting API only breaks delivery out by state. The attributed geography is now two resolution steps coarser than the canonical geography and a different geometry family (admin polygon vs. H3-derived circles) than the executed geography. Any lift measured against state-level delivery is measuring a geography that was never actually targeted. **(f) Cell-level experiment assignment diverging from delivery.** A geo-experiment assigns treatment and control at the cell level using [inscribed circles](/docs/h3-to-inscribed-circle/) to guarantee no spill between neighboring cells. If the media platform instead delivers on circumscribed circles for reach, the executed footprint spills into neighboring cells that the experiment design assumed were clean control — contaminating the read without any single stage being "wrong" in isolation. ## Provenance has to survive every hop Each conversion above should append to a `ConversionRecord`, never overwrite the one before it. At minimum the record needs the source CRS and boundary vintage, the normalization actions taken, the H3 resolution and containment mode, the execution approximation mode (which circle, which simplification tolerance, which crosswalk vintage), and which inclusions or exclusions were dropped because a platform could not express them. Losing any one field converts a checkable claim ("intersect-fill polyfill at resolution 8, executed as circumscribed circles rounded to platform radius increments") into an unfalsifiable one ("we targeted the area"). > **Note:** Case (b) and (c) both depend on a boundary vintage. A DMA or ZCTA crosswalk built from a two-year-old file will misassign cells near any line that has since moved — see [stale boundaries](/docs/geometry-catalogue/) — and that misassignment looks identical, in the data, to a correct crosswalk on an outdated boundary. The only defense is recording `validFrom`/`validTo` on every crosswalk and refusing to join across a vintage gap. > **Note:** Whenever a sentence about geography could be replaced by one of the six rows above, replace it. "Targeted the county" becomes "targeted the resolution-8 intersect-fill polyfill of the 2024-vintage county boundary, executed as circumscribed circles." The second sentence is checkable; the first is not. --- # Resolution Selection > Choosing an H3 resolution trades boundary fidelity against inventory size, computation cost, and privacy risk, and the right tradeoff depends on the intent, not on a fixed rule. - **URL:** https://etherdata.ai/blog/geo-interop-kb/docs/resolution-selection - **Category:** concepts - **Edge cases:** mixed-resolutions, minimum-radius, tiny-polygons - **Related:** conversion-profiles, mixed-h3-resolutions, platform-target-count-constraints --- H3 resolution is not a single knob tuned for "accuracy." It sits at the intersection of at least twelve independent constraints, several of which push in opposite directions. A resolution chosen for boundary fidelity can violate a platform's target-count limit; a resolution chosen to satisfy a privacy threshold can be too coarse for the experimental unit it needs to support. This page is a decision guide, not a lookup table — the profiles at the end are defaults for common intents, not universal truths. ## The reference table Each step up in H3 resolution shrinks average cell edge length by roughly a factor of 2.6 and average cell area by roughly a factor of 7 (approximate values, per the h3geo.org resolution table): | | | |---|---| | Res 5 | ~252.9 km2 avg area, ~8.5 km avg edge length | | Res 6 | ~36.1 km2 avg area, ~3.2 km avg edge length | | Res 7 | ~5.16 km2 avg area, ~1.2 km avg edge length | | Res 8 | ~0.737 km2 avg area, ~0.46 km avg edge length | | Res 9 | ~0.105 km2 avg area, ~0.17 km avg edge length | These are averages over all cells at a resolution, not a per-cell guarantee: individual cells vary in area and edge length depending on their position relative to the icosahedron (see [pentagons](/docs/h3-pentagons/) and face-crossing distortion), and the variance grows at coarser resolutions. Treat the table as an order-of-magnitude guide for planning, not as a per-cell specification. ## The twelve constraints **Geometry size and boundary complexity.** A resolution should be fine enough that the source polygon's boundary is not dominated by a handful of cells — a jagged coastline or a county line with many inflections needs a finer resolution than a smooth ellipse of the same area to keep boundary-disagreement area small relative to total area. **Coordinate accuracy.** Resolution finer than the source coordinate precision is false precision. A bidstream ping rounded to two decimal degrees (roughly 1.1 km of latitude error) cannot support resolution 9 (edge ~170 m) — cap the effective resolution to the coordinate's actual precision, not its nominal one. **Audience or inventory density.** Sparse-audience geographies need coarser cells to accumulate enough observations per cell to be statistically or privacy-meaningfully non-zero; dense urban geographies can support finer cells without emptying most of them. **Platform minimum radius.** If execution will be a point+radius circle, the [inscribed or circumscribed radius](/docs/h3-to-inscribed-circle/) at the chosen resolution must clear the platform's minimum-radius floor — resolution 9 cells are frequently too small to produce a radius any DSP will accept, forcing a coarser resolution regardless of boundary fidelity. **Target-count limits.** Platforms cap the number of discrete targets per line item. A fine resolution over a large area can produce an inventory of cells that exceeds the cap before compaction; see [platform target-count constraints](/docs/platform-target-count-constraints/). **Privacy threshold.** A resolution fine enough to isolate a household violates k-anonymity norms; privacy-safe profiles enforce both a minimum physical cell size and a minimum audience count per reported cell, and will coarsen resolution specifically to clear that floor. **Experimental unit.** Geo-experiments need units small enough to allow many independent replicates but large enough that adjacent units do not leak treatment into control through normal population movement — usually a coarser resolution (5-7) with a buffer (inscribed circles or gaps) rather than the finest resolution available. **Reporting granularity.** If the downstream report only breaks out by state or DMA, executing at resolution 9 buys precision that is destroyed at the reporting join — resolution should match the coarsest mandatory reporting join in the pipeline, not exceed it for no visible benefit. **Computation cost.** Cell count grows roughly sevenfold per resolution step; polyfilling, crosswalking, and metric computation over a country-scale polygon at resolution 9 is a materially larger job than the same polygon at resolution 6, with cost that compounds across every downstream join. **Population density variance.** A single fixed resolution over both dense urban cores and sparse rural areas will over-fragment the city and under-resolve the countryside; this is the core argument for [mixed resolutions](/docs/mixed-h3-resolutions/) rather than one resolution for an entire geography. **Crosswalk stability.** Finer resolutions produce more cells per admin region, each with a smaller intersection fraction, which is more sensitive to boundary vintage drift — a crosswalk built for long-term stability should favor a coarser resolution even if a finer one is available. **Expected inventory.** The number of cells actually available for targeting or measurement after compaction and platform constraints is the real deliverable; resolution choice should be checked against expected post-compaction inventory, not against the pre-compaction cell count. ## Recommended profiles — not universal truths | | | |---|---| | Admin partition / reporting rollup | Res 7-8: fine enough to track county/DMA boundaries, coarse enough to keep crosswalks stable and inventory manageable. | | Store trade-area / proximity targeting | Res 8-9: fine enough to resolve individual retail catchments; verify against platform minimum radius before committing. | | Geo-experiment treatment/control | Res 5-7 with inscribed-circle buffering: coarser units reduce control contamination even at some cost to replicate count. | | Privacy-constrained audience reporting | Res 6-7, degraded further per-cell if the audience threshold is not met: resolution is a privacy control here, not a fidelity control. | | National-scale planning / DMA-only platforms | Res 4-5: matches city/DMA grain; finer resolution buys nothing a DMA-level platform can express. | > **Note:** Every row above can be wrong for a specific case. A privacy-safe profile at resolution 7 in a dense downtown core may still clear the audience threshold at resolution 9; a proximity profile at resolution 9 in a rural trade area may produce mostly empty cells that resolution 7 would have served better. Check the actual constraint list above against the actual geography before applying a profile from this table. ## Edge cases Mixed-resolution sets ([mixed-resolutions](/docs/mixed-h3-resolutions/)) arise naturally when different regions of one target need different resolutions for density reasons; they must be normalized to a common resolution before set operations, never compared as-is. A platform's minimum-radius floor ([minimum-radius](/docs/platform-target-count-constraints/)) can force a coarser resolution than boundary fidelity alone would choose. Tiny polygons smaller than a single cell at the chosen resolution may receive zero center-contained cells regardless of how important the target is — resolution selection for small trade areas should be checked against the source polygon's actual area, not assumed from a profile. ## Illustration — the same square at three resolutions > Figure: R7 · 7 cells > Figure: R8 · 34 cells > Figure (poly-r9): R9 · 171 cells — finer resolution hugs the boundary but multiplies the target count roughly 7x per step. --- # S2 Overview > S2 as a system: cube-to-sphere projection, exact quad hierarchy (4 children exactly tile every parent), 31 levels, Hilbert-curve cell IDs, and quadrilateral cells that are not equal-area. - **URL:** https://etherdata.ai/blog/geo-interop-kb/docs/s2-overview - **Category:** systems · **Exactness:** approximate - **Source geometry:** h3_cell_set - **Destination geometry:** h3_cell_set - **Edge cases:** face-crossing-cells, mixed-resolutions - **Related:** cell-system-comparison, h3-overview --- approximate ## What S2 is S2 is a discrete global grid system originating at Google. It projects a cube onto the sphere — each of the cube's 6 faces maps to a curved quadrilateral region of the sphere — and subdivides each face hierarchically into smaller quadrilaterals, producing a grid of quadrilateral cells at 31 levels, numbered 0 (a whole cube face) through 30 (finest). Like [H3](/docs/h3-overview/) and [Geohash](/docs/geohash-overview/), S2 is one of the non-canonical cell systems this knowledge base's generic model must support without assuming H3-specific properties; see [cell-system-comparison](/docs/cell-system-comparison/) for the full cross-system matrix. ## Hierarchy: exact quad subdivision S2's defining structural property, and the sharpest contrast with H3, is that its hierarchy is an **exact** quad tree: every cell at level N is subdivided into exactly 4 children at level N+1, and the union of those 4 children's true boundaries exactly reproduces the parent's true boundary, with no gap and no overlap. This is a stronger and qualitatively different guarantee than H3's aperture-7 hierarchy, where a parent's 7 (approximate) children are a logical, index-arithmetic relationship that does not exactly geometrically tile the parent. Practically: coarsening or refining an S2 cell set by walking the cell-ID hierarchy reproduces the same covered region exactly, at any level, with no boundary drift — whereas the same operation on an H3 cell set can introduce small boundary discrepancies that must be measured (`coverage_ratio`, `jaccard`), not assumed away. ## Levels and cell size S2 has 31 levels (0-30). A level-0 cell is one sixth of the sphere's surface (a full cube face); each level down quarters the area of the level above, so cell area shrinks by a factor of 4 per level, versus H3's factor of roughly 7 per resolution — the two numbering systems are independent and do not correspond level-for-level. S2 is explicitly **not** equal-area: the cube-to-sphere projection distorts area non-uniformly across a face, so a cell near a face's center is smaller than a same-level cell near a face edge or corner — comparable in kind, though not magnitude or pattern, to H3's projection-driven area variance. Neither S2 nor H3 is equal-area. ## Quadrilateral cells and neighbours S2 cells are quadrilaterals everywhere — there is no pentagon-equivalent structural exception the way H3 has 12 unavoidable pentagons per resolution. A typical S2 cell has 4 edge-adjacent neighbours; cells at a cube face's corner or along a face boundary can have additional neighbour relationships to account for the discrete jump between the two adjoining faces' coordinate systems, but this is a face-boundary bookkeeping detail, not a shape exception comparable to an H3 pentagon. ## Index representation: Hilbert curve cell ID Each S2 cell is addressed by a 64-bit integer whose bits encode the cube face (3 bits, for 6 faces) followed by the cell's position along a Hilbert space-filling curve traversal of that face at the target level. The Hilbert-curve ordering is deliberate: cells that are numerically close in S2 cell-ID order are also spatially close on the sphere, a useful locality property for range-based spatial indexing (a database B-tree or key-range scan over S2 cell IDs tends to group nearby cells) — distinct from H3's index, which encodes an explicit resolution/base-cell/digit-path structure rather than a curve position. ## Core operations | | | |---|---| | Point indexing | A lat/lng point maps to its containing S2 cell ID at a given level via the cube-face projection followed by Hilbert-curve position lookup — the S2 equivalent of H3's `latLngToCell`. | | Polygon covering | `S2RegionCoverer` produces a cell covering for an arbitrary region, parameterized by min/max level and a max-cells budget, analogous in purpose to H3's `polygonToCells` but tuned by a cell-count budget rather than a single containment mode. | | Boundary extraction | Each S2 cell's exact quadrilateral vertex boundary can be recovered from its cell ID and level, the S2 equivalent of `cellToBoundary`. | | Centroid extraction | A cell's center point is derivable from its cell ID, analogous to `cellToLatLng`. | | Compaction | Because a parent cell ID's children are a fixed, deterministic range of cell IDs (all descendants of a level-N cell fall within a contiguous ID range), S2 supports compacting a set of same-level cells into coarser parent IDs where a full set of 4 (or a full descendant range) is present — implemented via cell-ID range arithmetic rather than a bespoke compaction algorithm. | ## Relevance: exact containment for spatial indexing and joins S2's exact quad containment is specifically why it is a common choice for spatial indexing and geometric joins in database and search systems: a range query over Hilbert-ordered S2 cell IDs reliably returns all cells within a region with no approximation-driven boundary leakage between parent and child levels, and "does cell A contain cell B" is answerable by comparing cell-ID ranges alone, without a geometric boundary computation. H3 cannot offer that same guarantee, because its hierarchy is logical rather than exact — which is precisely why this knowledge base recommends H3 for advertising execution (where hexagon-shaped catchment approximation and aperture-7 resolution steps are the relevant properties) while flagging S2 as the stronger choice specifically where exact containment is load-bearing, such as backing a spatial join or a range-indexed store. ## What must not be assumed Do not assume S2 shares H3's resolution numbering, aperture-7 area ratio, hexagon shape, 6-neighbour structure, or pentagon exceptions — none of these apply. Do not assume S2 is equal-area — it is not, though its area variance arises from a cube projection rather than an icosahedron projection. Do assume S2's parent-child containment is exact, which is the one hierarchy property that is *stronger*, not weaker, than H3's. ## References - S2 Geometry — Google, [s2geometry.io](https://s2geometry.io/) (last verified 2026-07-22) ## Assumptions and limitations This page describes S2's generic capability model as registered in `data/cell-systems.yaml`. Exact level count, cell-ID bit layout, and `S2RegionCoverer` parameter defaults should be verified against the current S2 library documentation before being relied on for a production calculation. ## Illustration — a real S2 cell > Figure (cell-s2): An actual S2 cell (s2-geometry) at level 13: a quadrilateral with 4 edge neighbours; 4 children exactly tile it. ======================================================================== # EDGE-CASE CATALOGUE ======================================================================== --- # Antimeridian (±180° meridian) > Geometries crossing ±180° longitude wrap incorrectly, producing world-spanning artifacts when treated as planar. - **URL:** https://etherdata.ai/blog/geo-interop-kb/edge-cases/antimeridian - **Category:** global ## Detection Ring longitude span > 180° or sign change across ±180°. ## Mitigation - Split geometry at the antimeridian - Densify edges along great circles - Use h3 isGeoJson handling / unwrap longitudes ## Affected conversions - Polygon → H3 (intersects) - Point+radius → H3 - H3 → exact polygon --- # H3 pentagons > 12 pentagon cells per resolution sit at icosahedron vertices; they break the 6-neighbour and regular-shape assumptions and have lower inscribed/circumscribed ratios. - **URL:** https://etherdata.ai/blog/geo-interop-kb/edge-cases/pentagons - **Category:** global ## Detection isPentagon(cell). ## Mitigation - Handle pentagons explicitly - Do not assume a hexagon inscribed ratio of cos(30°) - Report per-cell shape regularity ## Affected conversions - H3 → exact polygon - H3 → inscribed circle - H3 → circumscribed circle --- # Icosahedron face-crossing cells > Cells spanning two icosahedron faces are distorted; edges are not symmetric and area varies. - **URL:** https://etherdata.ai/blog/geo-interop-kb/edge-cases/face-crossing-cells - **Category:** global ## Detection Compare edge lengths / inscribed:circumscribed ratio against the resolution norm. ## Mitigation - Densify edges - Compute circles from the true boundary, never from a nominal edge length ## Affected conversions - H3 → exact polygon - H3 → inscribed circle --- # Self-intersecting polygon > Bowtie/overlapping rings make area and containment undefined. - **URL:** https://etherdata.ai/blog/geo-interop-kb/edge-cases/self-intersections - **Category:** geometry ## Detection turf.kinks returns intersection points. ## Mitigation - Repair upstream (buffer(0) / make-valid) - Reject and flag rather than silently fill ## Affected conversions - Polygon → H3 (fully-contained) - Polygon → H3 (intersects) --- # Polygon holes > Interior rings (donuts) must be respected so cells inside a hole are excluded. - **URL:** https://etherdata.ai/blog/geo-interop-kb/edge-cases/holes - **Category:** geometry ## Detection Polygon has > 1 ring. ## Mitigation - Pass all rings to the filler - Verify hole winding (CW) after normalization ## Affected conversions - Polygon → H3 (center-contained) - Polygon → H3 (fully-contained) - Polygon → H3 (intersects) --- # Narrow / sliver polygons > Polygons thinner than a cell can yield zero center-contained cells. - **URL:** https://etherdata.ai/blog/geo-interop-kb/edge-cases/narrow-polygons - **Category:** geometry ## Detection Center-fill returns empty while area > 0. ## Mitigation - Use intersect mode - Increase resolution - Seed from boundary vertices ## Affected conversions - Polygon → H3 (center-contained) - Polygon → H3 (fully-contained) --- # Tiny polygons > Polygons much smaller than a cell may be missed or over-represented by a single cell. - **URL:** https://etherdata.ai/blog/geo-interop-kb/edge-cases/tiny-polygons - **Category:** geometry ## Detection Polygon area << cell area at the chosen resolution. ## Mitigation - Increase resolution - Consider point+radius instead ## Affected conversions - Polygon → H3 (center-contained) - Polygon → H3 (intersects) --- # Touching-only intersection > A cell that only shares a boundary point/edge (zero area) with the polygon. - **URL:** https://etherdata.ai/blog/geo-interop-kb/edge-cases/touching-only - **Category:** geometry ## Detection Intersection area ≈ 0 despite booleanIntersects true. ## Mitigation - Require intersection_area > ε, not mere touching ## Affected conversions - Polygon → H3 (fully-contained) - Polygon → H3 (intersects) --- # Simplified boundaries > Douglas-Peucker-style simplification shifts the boundary, moving which cells qualify. - **URL:** https://etherdata.ai/blog/geo-interop-kb/edge-cases/simplified-boundaries - **Category:** geometry ## Detection Compare vertex counts / boundary displacement against source. ## Mitigation - Record simplification tolerance - Report boundary displacement metric ## Affected conversions - Polygon → H3 (intersects) - Polygon → H3 (coverage threshold) --- # MultiPolygon / multipart > Disjoint parts (islands, exclaves) must all be filled; a single-ring assumption drops parts. - **URL:** https://etherdata.ai/blog/geo-interop-kb/edge-cases/multipart-geometries - **Category:** geometry ## Detection Geometry type is MultiPolygon. ## Mitigation - Iterate all parts - Preserve part membership in provenance ## Affected conversions - Polygon → H3 (intersects) - Admin polygons → H3 (weighted crosswalk) --- # Mixed-resolution cell set > A set mixing resolutions cannot be compared or subtracted without normalization. - **URL:** https://etherdata.ai/blog/geo-interop-kb/edge-cases/mixed-resolutions - **Category:** hierarchy ## Detection getResolution differs across the set. ## Mitigation - normalizeToResolution before set ops - Decide compaction policy explicitly ## Affected conversions - H3 → platform-native IDs - Cell system → cell system --- # Parent + child in one set > A parent and one of its descendants both present double-count the shared area. - **URL:** https://etherdata.ai/blog/geo-interop-kb/edge-cases/parent-child-duplicates - **Category:** hierarchy ## Detection hasParentChildDuplicate (ancestor present in set). ## Mitigation - Compact then uncompact to a target resolution - Normalize before union/difference ## Affected conversions - Cell system → cell system --- # Platform minimum radius > A platform floor (e.g. 1 km) makes sub-floor cells un-executable as circles. - **URL:** https://etherdata.ai/blog/geo-interop-kb/edge-cases/minimum-radius - **Category:** platform ## Detection circle radius < platform.minRadius. ## Mitigation - Coarsen resolution until inscribed radius ≥ min - Merge cells before circling ## Affected conversions - Point+radius → H3 - H3 → inscribed circle - H3 → circumscribed circle - H3 cell set → optimized circle cover --- # Radius rounding / increments > Platforms round radii to increments, changing coverage/overlap. - **URL:** https://etherdata.ai/blog/geo-interop-kb/edge-cases/radius-increments - **Category:** platform ## Detection Compare requested radius to platform-quantized radius. ## Mitigation - Round outward for coverage, inward for isolation - Recompute metrics on the rounded radius ## Affected conversions - H3 → inscribed circle - H3 → circumscribed circle - H3 cell set → optimized circle cover --- # Platform accepts only native IDs > No polygons/coordinates — everything must be crosswalked to platform IDs, losing sub-unit precision. - **URL:** https://etherdata.ai/blog/geo-interop-kb/edge-cases/platform-native-ids-only - **Category:** platform ## Detection platform.polygonSupport=false and coordinateSupport=false. ## Mitigation - Maintain a versioned crosswalk - Report unmatched cells and precision loss ## Affected conversions - H3 → platform-native IDs --- # Unsupported exclusions > 'Include A minus B' cannot be expressed on platforms without exclusion support; the exclusion is silently dropped. - **URL:** https://etherdata.ai/blog/geo-interop-kb/edge-cases/unsupported-exclusions - **Category:** platform ## Detection platform.exclusionSupport=false with a non-empty exclusion set. ## Mitigation - Pre-subtract in cell space and target only the difference - Flag reported≠executed ## Affected conversions - H3 → platform-native IDs --- # Axis-order reversal (lat/lng swap) > Coordinates supplied as [lat,lng] where [lng,lat] is expected place geometry in the wrong hemisphere. - **URL:** https://etherdata.ai/blog/geo-interop-kb/edge-cases/axis-order-reversal - **Category:** data_quality ## Detection Coordinate out of range but valid when swapped. ## Mitigation - Range-check and swap-detect - Reject rather than guess when ambiguous ## Affected conversions - Polygon → H3 (intersects) - Point → H3 --- # Rounded / truncated coordinates > Bidstream coordinates rounded to 2–3 decimals snap to a coarse grid, biasing cell assignment. - **URL:** https://etherdata.ai/blog/geo-interop-kb/edge-cases/rounded-coordinates - **Category:** data_quality ## Detection Coordinate precision below the cell edge length. ## Mitigation - Cap effective resolution to the coordinate precision - Treat as point+accuracy, not exact ## Affected conversions - Point → H3 --- # Raster no-data values > Sentinel no-data pixels (e.g. -9999) corrupt aggregates if not masked. - **URL:** https://etherdata.ai/blog/geo-interop-kb/edge-cases/nodata-values - **Category:** data_quality ## Detection Pixel equals the declared nodata value. ## Mitigation - Mask nodata before aggregation - Record coverage fraction of valid pixels per cell ## Affected conversions - Raster → H3 --- # Raster/cell resolution mismatch > A coarse raster over fine cells yields false precision; fine raster over coarse cells needs area weighting. - **URL:** https://etherdata.ai/blog/geo-interop-kb/edge-cases/resolution-mismatch - **Category:** data_quality ## Detection Compare pixel size to cell edge length. ## Mitigation - Area-weighted aggregation - Cap cell resolution to raster resolution ## Affected conversions - Raster → H3 --- # Coastal / mixed pixels > Pixels straddling land/water mislabel coastal cells. - **URL:** https://etherdata.ai/blog/geo-interop-kb/edge-cases/coastal-pixels - **Category:** data_quality ## Detection Land mask disagreement within a cell. ## Mitigation - Apply a land/water mask - Report per-cell land fraction ## Affected conversions - Raster → H3 --- # Stale boundaries > Admin/postal/DMA boundaries change; using an old vintage misassigns cells. - **URL:** https://etherdata.ai/blog/geo-interop-kb/edge-cases/stale-boundaries - **Category:** temporal ## Detection Boundary vintage older than the activity period. ## Mitigation - Version boundaries - Record validFrom/validTo on every crosswalk ## Affected conversions - Admin polygons → H3 (max-overlap partition) - Admin polygons → H3 (weighted crosswalk) - H3 → platform-native IDs --- # DMA redefinition > Media markets are periodically redrawn; crosswalks must be pinned to a vintage. - **URL:** https://etherdata.ai/blog/geo-interop-kb/edge-cases/dma-changes - **Category:** temporal ## Detection Vendor vintage change. ## Mitigation - Pin DMA vintage - Re-run crosswalk on redefinition ## Affected conversions - Admin polygons → H3 (weighted crosswalk) --- # Postal boundary changes > ZIP/ZCTA definitions drift between vintages. - **URL:** https://etherdata.ai/blog/geo-interop-kb/edge-cases/postal-boundary-changes - **Category:** temporal ## Detection ZCTA vintage mismatch. ## Mitigation - Pin vintage - Prefer point-set semantics where ZIP is not a polygon ## Affected conversions - Admin polygons → H3 (weighted crosswalk) --- # Duplicated region IDs > The same admin id mapping to multiple polygons (data error) breaks partition assumptions. - **URL:** https://etherdata.ai/blog/geo-interop-kb/edge-cases/duplicated-region-ids - **Category:** data_quality ## Detection id appears on > 1 disjoint feature unexpectedly. ## Mitigation - Dedupe / union by id - Fail loudly on unexpected duplicates ## Affected conversions - Admin polygons → H3 (max-overlap partition) - Admin polygons → H3 (weighted crosswalk) --- # IP-derived location > IP geolocation is coarse and often centroid-biased; it is not physical presence. - **URL:** https://etherdata.ai/blog/geo-interop-kb/edge-cases/ip-derived-location - **Category:** advertising ## Detection locationSource = ip. ## Mitigation - Cap resolution - Label matching semantic explicitly (not physical_presence) ## Affected conversions - Point → H3 --- # Consent-based precision reduction > Consent state can coarsen or drop coordinates, changing cell assignment. - **URL:** https://etherdata.ai/blog/geo-interop-kb/edge-cases/consent-precision - **Category:** privacy ## Detection Consent flag indicates reduced precision. ## Mitigation - Degrade resolution to match consent - Never up-sample coarsened data ## Affected conversions - Point → H3 - Point+radius → H3 --- # Sparse-audience suppression > Cells with too few users risk re-identification and must be suppressed or coarsened. - **URL:** https://etherdata.ai/blog/geo-interop-kb/edge-cases/sparse-audience-suppression - **Category:** privacy ## Detection Per-cell audience below k-anonymity threshold. ## Mitigation - Suppress or roll up to a coarser resolution - Enforce minimum aggregation window ## Affected conversions - Point → H3 - Raster → H3 --- # Device trajectory exposure > Ordered high-resolution trajectories are re-identifying even when individual points are not. - **URL:** https://etherdata.ai/blog/geo-interop-kb/edge-cases/device-trajectory-exposure - **Category:** privacy ## Detection Trajectory uniqueness at the chosen resolution. ## Mitigation - Coarsen space/time - Drop order or aggregate to OD pairs ## Affected conversions - Line/corridor → H3 --- # GPS noise / boundary oscillation > Noisy fixes near a boundary flip cells back and forth, inflating counts. - **URL:** https://etherdata.ai/blog/geo-interop-kb/edge-cases/gps-noise - **Category:** advertising ## Detection High-frequency cell alternation within the accuracy radius. ## Mitigation - Smooth trajectories - Snap using accuracy radius - Debounce boundary crossings ## Affected conversions - Point → H3 - Line/corridor → H3 --- # Corridor boundary oscillation > A path skimming a cell edge produces a jagged, duplicated cell sequence. - **URL:** https://etherdata.ai/blog/geo-interop-kb/edge-cases/boundary-oscillation - **Category:** advertising ## Detection Repeated A→B→A cell transitions. ## Mitigation - Buffer the corridor - Deduplicate consecutive repeats ## Affected conversions - Line/corridor → H3 --- # Duplicate observations > The same impression/visit counted multiple times inflates audience per cell. - **URL:** https://etherdata.ai/blog/geo-interop-kb/edge-cases/duplicate-observations - **Category:** measurement ## Detection Repeated dedup keys within a window. ## Mitigation - Deduplicate by key+window before aggregation ## Affected conversions - Point → H3 --- # Nested holes > A hole containing an island (hole-in-hole-in-fill) violates simple-polygon assumptions; naive ring parity gets the interior/exterior classification backwards. - **URL:** https://etherdata.ai/blog/geo-interop-kb/edge-cases/nested-holes - **Category:** geometry ## Detection Ring nesting depth > 2 (exterior -> hole -> island -> hole...) detected via point-in-ring containment tests between rings. ## Mitigation - Flatten to a single fill/hole pair per nesting level via GEOS/turf union - Validate with even-odd or nonzero winding rule before filling - Reject geometries with nesting depth > 3 pending manual review ## Affected conversions - Polygon → H3 (center-contained) - Polygon → H3 (fully-contained) - Polygon → H3 (intersects) --- # Duplicate vertices > Consecutive repeated coordinates (zero-length segments) in a ring can produce degenerate edges that break area and intersection calculations. - **URL:** https://etherdata.ai/blog/geo-interop-kb/edge-cases/duplicate-vertices - **Category:** geometry ## Detection Scan ring coordinates for consecutive points with distance approximately 0. ## Mitigation - Dedupe consecutive identical coordinates before processing - Run through a geometry-repair pass (e.g. GEOS makeValid) prior to filling ## Affected conversions - Polygon → H3 (intersects) - Polygon → H3 (coverage threshold) - Line/corridor → H3 --- # Unclosed rings > A polygon ring whose first and last coordinates differ is not technically closed, causing GeoJSON-strict fillers to throw or silently miscompute area. - **URL:** https://etherdata.ai/blog/geo-interop-kb/edge-cases/unclosed-rings - **Category:** geometry ## Detection First coordinate does not equal last coordinate for any ring. ## Mitigation - Auto-close by appending the first vertex - Reject upstream sources that produce unclosed rings and flag for a data-contract fix ## Affected conversions - Polygon → H3 (center-contained) - Polygon → H3 (fully-contained) - Polygon → H3 (intersects) --- # Incorrect ring winding order > GeoJSON requires exterior rings counter-clockwise and holes clockwise (right-hand rule); reversed winding flips interior/exterior for winding-sensitive fillers. - **URL:** https://etherdata.ai/blog/geo-interop-kb/edge-cases/incorrect-winding - **Category:** geometry ## Detection Compute signed area (shoelace formula); sign mismatch against RFC 7946 convention for the ring role. ## Mitigation - Normalize winding on ingest (rewind exterior CCW, holes CW) - Validate with a strict GeoJSON linter before filling ## Affected conversions - Polygon → H3 (center-contained) - Polygon → H3 (fully-contained) - Polygon → H3 (intersects) --- # Empty geometries > A feature with a null or zero-ring geometry (e.g. GEOMETRYCOLLECTION EMPTY, or coordinates: []) produces no cells and can silently vanish from an audience or trade-area union. - **URL:** https://etherdata.ai/blog/geo-interop-kb/edge-cases/empty-geometries - **Category:** geometry ## Detection Geometry is null, coordinates array is empty, or ring count is 0. ## Mitigation - Filter and log empty geometries at ingest rather than let them drop silently - Alert when a feature count declines after a geometry-processing step ## Affected conversions - Polygon → H3 (center-contained) - Polygon → H3 (fully-contained) - Polygon → H3 (intersects) - Admin polygons → H3 (max-overlap partition) --- # Invalid coordinate values > NaN, Infinity, out-of-range (|lat|>90, |lng|>180), or null coordinate components crash or silently corrupt downstream H3 indexing. - **URL:** https://etherdata.ai/blog/geo-interop-kb/edge-cases/invalid-coordinates - **Category:** geometry ## Detection Range/type check every coordinate: is-finite, |lat|<=90, |lng|<=180. ## Mitigation - Validate and reject on ingest rather than let latLngToCell throw mid-batch - Log source + row for any rejected coordinate for upstream fixes ## Affected conversions - Polygon → H3 (intersects) - Point → H3 - Point+radius → H3 - Line/corridor → H3 --- # Polar singularities > Near the North/South poles, longitude lines converge to a point; planar buffering and equirectangular projections badly distort cell shape and area right at the pole cells. - **URL:** https://etherdata.ai/blog/geo-interop-kb/edge-cases/poles - **Category:** global ## Detection Cell centroid or boundary latitude within a few degrees of ±90°. ## Mitigation - Use geodesic (great-circle) buffering, never planar, near poles - Flag and manually review any cell whose boundary vertices approach ±90° latitude ## Affected conversions - Point+radius → H3 - H3 → exact polygon - H3 → inscribed circle - H3 → circumscribed circle --- # Coastal boundary ambiguity > Land/water boundary polygons vary by data vintage and mean-high-water definition; cells right at the coastline can flip land/water classification between sources. - **URL:** https://etherdata.ai/blog/geo-interop-kb/edge-cases/coastal-boundaries - **Category:** global ## Detection Compare land-mask membership for the same cell across two boundary vintages/sources; flag disagreements. ## Mitigation - Pin a single authoritative coastline source and vintage - Report a land-fraction confidence per coastal cell rather than a binary flag ## Affected conversions - Polygon → H3 (intersects) - Admin polygons → H3 (max-overlap partition) - Raster → H3 --- # Offshore islands > Small islands belonging to a mainland admin region are geometrically disjoint (multipolygon parts) and can be dropped by fillers that assume a single contiguous ring. - **URL:** https://etherdata.ai/blog/geo-interop-kb/edge-cases/offshore-islands - **Category:** global ## Detection MultiPolygon feature where one or more parts have a centroid far outside the bounding box of the largest part. ## Mitigation - Iterate all multipolygon parts explicitly, never just the largest - Verify per-part cell coverage in QA rather than aggregate area only ## Affected conversions - Polygon → H3 (intersects) - Admin polygons → H3 (max-overlap partition) - Admin polygons → H3 (weighted crosswalk) --- # Enclaves > A region entirely surrounded by another region's territory (e.g. Lesotho in South Africa) can be mis-assigned to the surrounding region by centroid- or overlap-based admin joins. - **URL:** https://etherdata.ai/blog/geo-interop-kb/edge-cases/enclaves - **Category:** global ## Detection Region polygon is fully contained within another region's polygon (within() true, not just intersects). ## Mitigation - Use polygon-in-polygon precedence rules, inner region wins on containment - Maintain an explicit enclave/exclave exceptions list for known cases ## Affected conversions - Admin polygons → H3 (max-overlap partition) - Admin polygons → H3 (weighted crosswalk) - H3 → platform-native IDs --- # Exclaves > A region's territory disconnected from its main body (e.g. Kaliningrad, Alaska) is geometrically a separate multipolygon part; overlap-max joins can attribute it to the surrounding region instead of its true owner. - **URL:** https://etherdata.ai/blog/geo-interop-kb/edge-cases/exclaves - **Category:** global ## Detection Named multipolygon part is geographically non-adjacent to the region's other parts (large centroid gap, no shared boundary). ## Mitigation - Preserve part-to-parent id mapping through the pipeline - Do not merge exclave parts with the geographically nearest neighbor by default ## Affected conversions - Admin polygons → H3 (max-overlap partition) - Admin polygons → H3 (weighted crosswalk) --- # Disputed territories > Areas with contested sovereignty (e.g. Crimea, Kashmir, Western Sahara) may appear in different admin boundary sources assigned to different countries, causing double-counting or gaps in cross-vendor crosswalks. - **URL:** https://etherdata.ai/blog/geo-interop-kb/edge-cases/disputed-territories - **Category:** global ## Detection Same geographic area claimed by two country/region features across vendor boundary sets; overlapping polygons from different sources. ## Mitigation - Pick and document a single boundary-source convention (e.g. de facto control) per delivery - Flag disputed-area cells in provenance metadata rather than silently picking one side ## Affected conversions - Admin polygons → H3 (max-overlap partition) - Admin polygons → H3 (weighted crosswalk) - H3 → platform-native IDs --- # Water-only cells > Cells whose entire footprint is open water (ocean, large lake) carry no population/audience but can still be generated by a naive grid fill over a bounding box or coastal buffer. - **URL:** https://etherdata.ai/blog/geo-interop-kb/edge-cases/water-cells - **Category:** global ## Detection Cell polygon land-fraction approximately 0 against a land/water mask. ## Mitigation - Mask against a land polygon before finalizing a cell set - Exclude or down-weight zero-land cells in audience/delivery counts ## Affected conversions - Polygon → H3 (intersects) - Point+radius → H3 - Raster → H3 --- # Geodesic versus planar geometry > Buffer, distance, and intersection operations computed on raw lat/lng as if it were a flat Cartesian plane diverge measurably from true great-circle geometry, worsening with distance and latitude. - **URL:** https://etherdata.ai/blog/geo-interop-kb/edge-cases/geodesic-vs-planar - **Category:** global ## Detection Compare planar buffer/distance output against a geodesic (e.g. Vincenty/haversine) computation for the same inputs; diverging error grows with radius and |latitude|. ## Mitigation - Use geodesic buffering/distance libraries (e.g. turf with units, or explicit ellipsoidal projection) for any radius beyond a few km - Document the max latitude/radius where planar approximation is acceptable ## Affected conversions - Point+radius → H3 - Line/corridor → H3 - H3 → inscribed circle - H3 → circumscribed circle --- # Web Mercator distortion > Web Mercator (EPSG:3857) inflates area and distance with latitude (infinite at the poles); using tile-based Mercator coordinates for area/radius math biases high-latitude markets. - **URL:** https://etherdata.ai/blog/geo-interop-kb/edge-cases/web-mercator-distortion - **Category:** global ## Detection Compare cell area computed in Mercator versus a true equal-area or geodesic method; error scales with sec(latitude). ## Mitigation - Never compute area/radius in EPSG:3857; reproject to an equal-area or geodesic method first - Reserve Mercator strictly for tile-display rendering, not measurement ## Affected conversions - Raster → H3 - H3 → exact polygon - H3 → circumscribed circle --- # Wrong CRS assumed > Coordinates delivered in a projected or regional CRS (e.g. State Plane, UTM) but consumed as if they were WGS84 lat/lng place points thousands of kilometers off with no error thrown. - **URL:** https://etherdata.ai/blog/geo-interop-kb/edge-cases/wrong-crs - **Category:** data_quality ## Detection Coordinate magnitudes inconsistent with WGS84 range (e.g. values in the millions typical of a projected CRS false easting/northing). ## Mitigation - Require and validate an explicit CRS tag on every geometry source - Reproject to WGS84 (EPSG:4326) before any H3 indexing step ## Affected conversions - Polygon → H3 (intersects) - Point → H3 --- # Missing CRS declaration > A geometry file with no CRS metadata forces an assumption (usually WGS84); if the true CRS differs, every downstream cell assignment is wrong with no detection signal. - **URL:** https://etherdata.ai/blog/geo-interop-kb/edge-cases/missing-crs - **Category:** data_quality ## Detection File/feature lacks a .prj, CRS property, or EPSG code; only inferable from coordinate magnitude sanity checks. ## Mitigation - Reject files without an explicit CRS at ingest - Sanity-check coordinate range against the assumed CRS's expected bounds before proceeding ## Affected conversions - Polygon → H3 (intersects) - Point → H3 - Raster → H3 --- # Missing polygons > A source dataset silently omits polygons for some regions (e.g. a boundary file missing a newly incorporated municipality), leaving gaps in cell coverage with no explicit null marker. - **URL:** https://etherdata.ai/blog/geo-interop-kb/edge-cases/missing-polygons - **Category:** data_quality ## Detection Expected region-id list (from a canonical registry) has entries absent from the delivered geometry set. ## Mitigation - Reconcile delivered feature ids against a canonical registry every load - Alert on any expected id with no matching geometry rather than fail silently ## Affected conversions - Polygon → H3 (intersects) - Admin polygons → H3 (max-overlap partition) - Admin polygons → H3 (weighted crosswalk) --- # Incomplete geometry > A polygon with fewer than 4 coordinates (or a ring truncated mid-transfer) is not a valid closed shape and will fail or misbehave in a filler. - **URL:** https://etherdata.ai/blog/geo-interop-kb/edge-cases/incomplete-geometry - **Category:** data_quality ## Detection Ring vertex count < 4 after closing, or coordinate array truncated relative to declared length/checksum. ## Mitigation - Validate vertex count and ring closure on ingest - Checksum or row-count validate file transfers to catch truncation ## Affected conversions - Polygon → H3 (center-contained) - Polygon → H3 (fully-contained) - Polygon → H3 (intersects) --- # Geocoding uncertainty > Address-to-point geocoding carries a precision tier (rooftop, street, ZIP centroid) that is often dropped downstream; a ZIP-centroid-precision point treated as rooftop-precision misassigns the cell. - **URL:** https://etherdata.ai/blog/geo-interop-kb/edge-cases/geocoding-uncertainty - **Category:** data_quality ## Detection Geocoder confidence/precision field below rooftop/parcel tier. ## Mitigation - Carry the geocoder precision tier through the pipeline - Cap effective H3 resolution to match the geocode precision tier, never index finer than the input warrants ## Affected conversions - Point → H3 --- # Zero-island coordinates > Null or unparsed lat/lng fields defaulting to (0,0) plot as real points in the Gulf of Guinea ('Null Island'), silently injecting fake density into that cell. - **URL:** https://etherdata.ai/blog/geo-interop-kb/edge-cases/zero-island - **Category:** data_quality ## Detection Coordinate exactly (0.0, 0.0), or a statistically anomalous point cluster at the null-island cell. ## Mitigation - Treat (0,0) as a null-parse sentinel, reject rather than index - Monitor cell 0,0's neighborhood for volume anomalies as a data-quality canary ## Affected conversions - Point → H3 --- # Compacted cell sets > H3 compact() collapses a full set of same-resolution children into their common parent wherever complete; consumers unaware of compaction assume single-resolution set operations. - **URL:** https://etherdata.ai/blog/geo-interop-kb/edge-cases/compacted-sets - **Category:** hierarchy ## Detection Resolutions vary across the set and no explicit compaction flag/marker is attached. ## Mitigation - Tag delivered sets as compact vs. uniform explicitly - uncompact() to a target resolution before any comparison, union, or platform delivery that assumes uniform resolution ## Affected conversions - H3 → platform-native IDs - Cell system → cell system --- # Logical versus geometric containment > H3 parent/child cells do not nest geometrically the way quadtree tiles do — a child's boundary is not fully inside its parent's boundary in every case, so geometric point-in-polygon tests can disagree with the logical h3ToParent relationship. - **URL:** https://etherdata.ai/blog/geo-interop-kb/edge-cases/logical-vs-geometric-containment - **Category:** hierarchy ## Detection Compute cellToParent(child) logically, then separately test point-in-polygon of the child centroid against the parent boundary polygon; flag disagreement. ## Mitigation - Always use h3ToParent/cellToChildren for hierarchy logic, never geometric point-in-polygon - Document to consumers that H3 hierarchy is index-based, not strictly geometric containment ## Affected conversions - Cell system → cell system --- # Resolution coercion > Forcing all inputs to a single target resolution (e.g. always coercing to r8) before comparison discards the finer detail of higher-resolution sources and introduces spurious precision on coarser ones. - **URL:** https://etherdata.ai/blog/geo-interop-kb/edge-cases/resolution-coercion - **Category:** hierarchy ## Detection Pipeline hardcodes a resolution constant rather than deriving target resolution from source precision or use case. ## Mitigation - Choose target resolution from the coarsest reliable input, not an arbitrary default - Document precision loss/gain per coercion step ## Affected conversions - Admin polygons → H3 (weighted crosswalk) - Cell system → cell system --- # Incomplete child sets > A set intended to represent 'all children of parent X at resolution N' is missing one or more children (e.g. due to an upstream filter or antimeridian/pentagon edge case), silently under-covering the parent's area. - **URL:** https://etherdata.ai/blog/geo-interop-kb/edge-cases/incomplete-child-sets - **Category:** hierarchy ## Detection count(children in set) != len(cellToChildren(parent, N)) for the expected resolution. ## Mitigation - Verify full child-set completeness against cellToChildren before compacting or delivering - Alert on any parent whose child coverage is partial rather than silently propagate a hole ## Affected conversions - Cell system → cell system --- # Platform maximum radius > Ad platforms often cap point-radius targeting at an upper bound (e.g. 50 km); a computed circumscribed or equal-area radius above that ceiling is silently clamped by the platform, shrinking actual coverage below what was reported. - **URL:** https://etherdata.ai/blog/geo-interop-kb/edge-cases/maximum-radius - **Category:** platform ## Detection Requested radius exceeds platform.maxRadius. ## Mitigation - Clamp and re-tile with multiple smaller circles instead of one oversized request - Report executed radius, not requested radius, in delivery reconciliation ## Affected conversions - Point+radius → H3 - H3 → circumscribed circle - H3 → equal-area circle --- # Unsupported polygon targeting > Some platforms accept only circles or native geo IDs, not arbitrary polygons; a custom trade-area polygon submitted directly is rejected or silently approximated by the platform's own simplification. - **URL:** https://etherdata.ai/blog/geo-interop-kb/edge-cases/unsupported-polygons - **Category:** platform ## Detection platform.polygonSupport=false while the target list contains raw polygon geometry. ## Mitigation - Pre-convert polygons to the platform's supported primitive (circle set or native ID crosswalk) rather than submit raw rings - Verify the platform's rendered target against the intended polygon post-submission ## Affected conversions - H3 → exact polygon - H3 → platform-native IDs --- # Platform coordinate rounding > Some platform APIs round submitted lat/lng to a fixed decimal precision (e.g. 4 decimals ≈ 11 m) before executing a circle target, shifting the effective center from the intended one. - **URL:** https://etherdata.ai/blog/geo-interop-kb/edge-cases/coordinate-rounding - **Category:** platform ## Detection Compare submitted coordinate precision/value against the platform's documented rounding behavior or the value echoed back by the API. ## Mitigation - Pre-round to the platform's known precision so reported and executed centers match - Add a radius margin sized to the maximum rounding-induced center shift ## Affected conversions - Point+radius → H3 - H3 → circumscribed circle --- # Undocumented deduplication > Some ad platforms silently dedupe overlapping target geographies or audience segments across line items without disclosing the rule, causing reported vs. delivered reach to diverge in ways not explainable from inputs alone. - **URL:** https://etherdata.ai/blog/geo-interop-kb/edge-cases/undocumented-deduplication - **Category:** platform ## Detection Delivered unique reach is materially lower than the sum of non-overlapping requested cells, with no documented overlap in the request. ## Mitigation - Request a delivery-level (not planning-level) geography breakdown from the platform where available - Independently pre-dedupe overlapping targets before submission so platform behavior can't be blamed for the gap ## Affected conversions - H3 → platform-native IDs - Cell system → cell system --- # Optimized targeting expansion > Platforms with 'audience expansion' or 'optimized targeting' features silently deliver impressions outside the submitted geography/audience to hit performance goals, breaking the assumption that delivery equals the requested target. - **URL:** https://etherdata.ai/blog/geo-interop-kb/edge-cases/optimized-targeting-expansion - **Category:** platform ## Detection Delivered impression geography (from platform reporting) includes cells/areas outside the submitted target set. ## Mitigation - Disable expansion/optimization features for geo-experiment cells specifically - Reconcile delivered vs. requested geography every flight and flag material leakage ## Affected conversions - Point+radius → H3 - H3 cell set → optimized circle cover - H3 → platform-native IDs --- # Reporting at a coarser level than execution > A platform executes targeting at a fine geography (e.g. H3 r8 or zip+4) but only reports delivery at a coarse level (e.g. DMA or state), masking whether the fine-grained target was actually honored. - **URL:** https://etherdata.ai/blog/geo-interop-kb/edge-cases/reporting-coarser-level - **Category:** platform ## Detection Reporting API's finest available geography dimension is coarser than the geography used at targeting time. ## Mitigation - Request the platform's most granular reporting breakdown available, even if coarser than execution - Treat unverifiable fine-grain execution as an assumption to be validated by independent measurement (e.g. geo-lift test), not platform reporting ## Affected conversions - Admin polygons → H3 (weighted crosswalk) - H3 → platform-native IDs --- # Asynchronous boundary updates > When an admin/postal boundary source updates, ad platforms and the advertiser's own crosswalk do not necessarily update on the same date; targeting and reporting can briefly use different boundary vintages for the same campaign. - **URL:** https://etherdata.ai/blog/geo-interop-kb/edge-cases/asynchronous-boundary-updates - **Category:** platform ## Detection Compare the boundary vintage timestamp used for targeting against the vintage used for the reporting join; flag mismatches. ## Mitigation - Pin and log the boundary vintage used at both targeting time and reporting time - Re-run the crosswalk and flag affected flights whenever a platform's boundary source updates mid-campaign ## Affected conversions - Admin polygons → H3 (weighted crosswalk) - H3 → platform-native IDs --- # Physical presence > A location signal claiming 'physical presence' may actually derive from a lower-confidence source (Wi-Fi, IP, declared) that has not been verified against a GPS dwell; treating all presence signals as equally reliable overstates confidence. - **URL:** https://etherdata.ai/blog/geo-interop-kb/edge-cases/physical-presence - **Category:** advertising ## Detection Location source/method field is absent or not GPS-derived while the segment is labeled physical presence. ## Mitigation - Require a minimum GPS accuracy and dwell time before labeling a signal physical presence - Carry the location source/method as provenance through to reporting ## Affected conversions - Point → H3 --- # Recent presence > 'Recent presence' segments depend entirely on the chosen lookback window; a location visit from 90 days ago counted as 'recent' in a stale segment misrepresents current audience composition. - **URL:** https://etherdata.ai/blog/geo-interop-kb/edge-cases/recent-presence - **Category:** advertising ## Detection Segment build lookback window undocumented or exceeds the campaign's stated recency claim. ## Mitigation - Define and enforce an explicit recency window (e.g. last 30 days) refreshed on a fixed cadence - Timestamp every segment build and expire audiences past their recency window ## Affected conversions - Point → H3 --- # Home location inference > Inferred 'home' location (typically the most common overnight device location over N days) can be wrong for shared devices, frequent travelers, or short observation windows, misassigning the household cell. - **URL:** https://etherdata.ai/blog/geo-interop-kb/edge-cases/home-location - **Category:** advertising ## Detection Overnight location observation count below the minimum threshold for a stable home inference, or two candidate locations with similar visit frequency (ambiguous). ## Mitigation - Require a minimum number of overnight observations across a minimum date span before inferring home - Flag and suppress ambiguous dual-candidate inferences rather than pick one arbitrarily ## Affected conversions - Point → H3 --- # Work location inference > Inferred 'work' location (typical daytime weekday device location) misfires for remote/hybrid workers, gig workers, and multi-site employees, assigning a work cell that doesn't reflect actual employment geography. - **URL:** https://etherdata.ai/blog/geo-interop-kb/edge-cases/work-location - **Category:** advertising ## Detection Daytime weekday location is dispersed across multiple cells with no single dominant cell above a confidence threshold. ## Mitigation - Require a minimum daytime-weekday visit concentration before inferring work - Treat remote-work-prevalent segments with lower confidence weighting or suppress the work-location field ## Affected conversions - Point → H3 --- # GPS-derived location accuracy > A GPS fix's reported horizontal accuracy (often tens to hundreds of meters, worse indoors/urban canyon) is frequently discarded downstream, letting a low-accuracy fix be indexed at a resolution finer than it can support. - **URL:** https://etherdata.ai/blog/geo-interop-kb/edge-cases/gps-derived-location - **Category:** advertising ## Detection Fix accuracy radius greater than the edge length of the H3 resolution being indexed to. ## Mitigation - Cap H3 indexing resolution to the fix's reported accuracy radius - Drop or down-weight fixes with accuracy worse than a defined ceiling (e.g. > 500 m) ## Affected conversions - Point → H3 --- # Location interest versus presence > 'Location interest' segments (built from search, content consumption, or app category signals about a place) are conflated with physical visitation, but a user searching for a location has not necessarily been there. - **URL:** https://etherdata.ai/blog/geo-interop-kb/edge-cases/location-interest - **Category:** advertising ## Detection Segment provenance is content/search-derived rather than location-observation-derived, while being marketed or reported as a presence audience. ## Mitigation - Label interest-based and presence-based segments distinctly in all reporting - Never blend interest and presence audiences into a single reported reach number ## Affected conversions - Point → H3 - Point+radius → H3 --- # Destination interest > Predictive 'likely to visit X' destination-interest scores are probabilistic, not observed; treating the resulting audience as equivalent to a confirmed-visitor segment overstates targeting precision. - **URL:** https://etherdata.ai/blog/geo-interop-kb/edge-cases/destination-interest - **Category:** advertising ## Detection Segment is built from a propensity/likelihood model score rather than an observed visit event. ## Mitigation - Report the underlying model's precision/recall alongside the segment - Use a documented score threshold, and disclose it, rather than presenting probabilistic membership as certain ## Affected conversions - Point → H3 - Point+radius → H3 --- # Publisher-declared location > Location supplied by the publisher/app (e.g. a self-reported profile city or content geography) rather than device-observed, is far coarser and more gameable than GPS or IP-derived signals, but is often merged into the same location field without a source tag. - **URL:** https://etherdata.ai/blog/geo-interop-kb/edge-cases/publisher-declared-location - **Category:** advertising ## Detection Bidstream/segment record has a location field with no accompanying source/method attribute distinguishing declared from observed. ## Mitigation - Require and preserve a location-source enum (declared vs. observed vs. inferred) on every record - Weight or exclude declared-location records separately in geo-targeting logic ## Affected conversions - Point → H3 --- # Cross-device location stitching > Location histories merged across a household's or individual's multiple devices via probabilistic ID-graph stitching can double-count visits or attribute one device's location to another device incorrectly, inflating cell-level audience counts. - **URL:** https://etherdata.ai/blog/geo-interop-kb/edge-cases/cross-device-location - **Category:** advertising ## Detection Same physical visit event appears against more than one device ID in the graph within an implausibly short time window (e.g. same location, same minute, two device IDs). ## Mitigation - Deduplicate at the household/individual (stitched) level before cell aggregation, not at raw device level - Track and report the ID-graph match confidence tier alongside any cross-device audience count ## Affected conversions - Point → H3 --- # Location confidence scoring > Vendors attach a confidence score to location signals, but many downstream pipelines drop it and treat every observation as equally trustworthy, letting low-confidence noise dilute high-confidence signal in aggregate cell counts. - **URL:** https://etherdata.ai/blog/geo-interop-kb/edge-cases/location-confidence - **Category:** advertising ## Detection Aggregation pipeline has no minimum confidence-score filter/threshold applied prior to cell counting. ## Mitigation - Apply and document a minimum confidence threshold before aggregation - Report an audience-weighted-average confidence score per cell alongside the raw count ## Affected conversions - Point → H3 - Raster → H3 --- # Lookback window sensitivity > Audience size and composition for a 'visited store X' segment change substantially with the chosen lookback window (7 vs. 30 vs. 90 days); an undocumented or inconsistent window makes cross-campaign comparisons invalid. - **URL:** https://etherdata.ai/blog/geo-interop-kb/edge-cases/lookback-window - **Category:** advertising ## Detection Segment definitions across campaigns/vendors reference 'recent visitors' without a stated day count, or day counts differ between compared segments. ## Mitigation - Standardize and document the lookback window per use case (planning vs. measurement) - Never compare audience sizes across segments built with different lookback windows ## Affected conversions - Point → H3 - Point+radius → H3 --- # Frequency across overlapping targets > When two or more targeted geographies overlap (e.g. a store radius nested inside a DMA target), a user in the overlap can receive frequency from both line items, inflating true frequency and reach reporting per user. - **URL:** https://etherdata.ai/blog/geo-interop-kb/edge-cases/frequency-overlapping-targets - **Category:** advertising ## Detection Compute geographic overlap between concurrently active targets; nonzero overlap area with independent frequency caps per line item. ## Mitigation - Apply a global (cross-line-item) frequency cap, not a per-line-item cap, wherever targets can overlap - Report deduplicated reach/frequency across the overlapping set, not summed ## Affected conversions - Point+radius → H3 - H3 → platform-native IDs - Cell system → cell system --- # Treatment/control contamination > In a geo-lift experiment, cells assigned to control can still receive treatment exposure via platform audience expansion, ad-adjacent delivery, or a resident's movement into a treatment cell, biasing the measured lift toward null. - **URL:** https://etherdata.ai/blog/geo-interop-kb/edge-cases/treatment-control-contamination - **Category:** advertising ## Detection Non-zero measured impression/exposure delivery within cells designated as control. ## Mitigation - Use a geographic buffer/no-man's-land between treatment and control cells to absorb spillover - Independently verify zero-delivery in control via platform reporting or third-party exposure logs, not assumption ## Affected conversions - Admin polygons → H3 (weighted crosswalk) - Cell system → cell system --- # Excluded-area leakage > A requested exclusion zone (e.g. exclude a competitor's trade area or a control market) can still receive delivery if the platform's exclusion granularity is coarser than the requested boundary, or exclusion isn't supported and is silently dropped. - **URL:** https://etherdata.ai/blog/geo-interop-kb/edge-cases/excluded-area-leakage - **Category:** advertising ## Detection Delivered impressions reported inside the geographic bounds of a declared exclusion zone. ## Mitigation - Pre-subtract exclusions in cell space and submit only the net-positive target rather than rely on platform-side exclusion support - Reconcile delivery against the exclusion boundary every flight ## Affected conversions - Point+radius → H3 - H3 → platform-native IDs --- # Bidstream truncation > High-volume bidstream feeds often truncate or sample records under load, and truncated records can silently drop the geo field entirely or truncate coordinate precision, biasing which impressions are geo-resolvable. - **URL:** https://etherdata.ai/blog/geo-interop-kb/edge-cases/bidstream-truncation - **Category:** advertising ## Detection Rate of missing/null geo fields spikes correlated with feed volume/load, or coordinate precision degrades under peak QPS versus off-peak. ## Mitigation - Monitor geo-field completeness rate as a function of feed volume and alert on drops - Treat volume-correlated geo-completeness dips as a truncation signal, not random missingness, when weighting geo-based aggregates ## Affected conversions - Point → H3 - Raster → H3 --- # Small-cell re-identification risk > A cell with very few observed individuals (e.g. 1-3) can be cross-referenced with public or other data sources to re-identify a specific person, even without any single field being personally identifiable on its own. - **URL:** https://etherdata.ai/blog/geo-interop-kb/edge-cases/small-cell-reidentification - **Category:** privacy ## Detection Per-cell unique-individual count below a defined re-identification risk threshold (commonly k=5 or higher depending on sensitivity). ## Mitigation - Suppress or roll up any cell below the k-anonymity threshold to a coarser resolution - Apply the same suppression rule consistently across all output cuts (time, cell, segment) of the same underlying data ## Affected conversions - Point → H3 - Raster → H3 --- # K-anonymity thresholds > Reporting a cell-level metric is only privacy-safe if at least k individuals share that cell/attribute combination; enforcing k only on the cell dimension while ignoring cross-tabulation with other dimensions (age, segment) can still expose small groups. - **URL:** https://etherdata.ai/blog/geo-interop-kb/edge-cases/k-anonymity - **Category:** privacy ## Detection Any reporting cut (cell x segment x time) with fewer than k unique individuals, even if the marginal cell total meets k alone. ## Mitigation - Enforce k-anonymity on every cross-tabulated cut that will be published, not just the top-level cell total - Suppress or merge categories in any cut that falls below k ## Affected conversions - Admin polygons → H3 (weighted crosswalk) - Point → H3 - Raster → H3 --- # Differential privacy noise calibration > Adding calibrated noise (e.g. Laplace/Gaussian mechanism) to protect individual privacy in aggregate counts can distort small-cell counts enough to invert rank ordering between nearby cells if the privacy budget (epsilon) is set too aggressively for the use case. - **URL:** https://etherdata.ai/blog/geo-interop-kb/edge-cases/differential-privacy - **Category:** privacy ## Detection Compare noised counts against raw (internal-only) counts for rank-order stability across repeated noise draws at the chosen epsilon. ## Mitigation - Tune epsilon per use case, tighter for public releases, looser for internal-only decision support with other controls - Report a confidence interval alongside any noised count so users don't over-read small differences ## Affected conversions - Point → H3 - Raster → H3 --- # Temporal leakage > Publishing a sequence of snapshots over time for the same fine-grained cell (even if each snapshot independently meets k-anonymity) can let an observer intersect the snapshots to re-identify an individual whose presence changed between them. - **URL:** https://etherdata.ai/blog/geo-interop-kb/edge-cases/temporal-leakage - **Category:** privacy ## Detection Compare successive published snapshots for the same cell; check whether the intersection of 'present in snapshot A and B' sets drops below the k-anonymity threshold. ## Mitigation - Apply k-anonymity to the full published time series, not each snapshot independently - Add temporal noise or coarsen the time grain when publishing repeated cuts of the same fine cell ## Affected conversions - Point → H3 - Raster → H3 --- # Household-level targeting risk > Targeting resolved down to a single-household granularity (e.g. one residential parcel or a cell containing exactly one dwelling) functions as individually addressable targeting even when labeled as 'neighborhood' targeting. - **URL:** https://etherdata.ai/blog/geo-interop-kb/edge-cases/household-level-targeting - **Category:** privacy ## Detection Target cell/geography contains a single residential structure or a household count of 1 per census/parcel data. ## Mitigation - Enforce a minimum household count per targetable unit, coarsen automatically if below threshold - Reject or flag targeting requests whose resolved geography maps to a single dwelling ## Affected conversions - Polygon → H3 (center-contained) - Point+radius → H3 - H3 → platform-native IDs --- # Minimum aggregation window > Reporting audience or visit counts over too short a time window (e.g. hourly) at fine spatial resolution can approach single-visit identifiability even if the raw count clears a k-anonymity floor for a longer window. - **URL:** https://etherdata.ai/blog/geo-interop-kb/edge-cases/minimum-aggregation-window - **Category:** privacy ## Detection Reporting time grain shorter than the vendor's/policy's documented minimum aggregation window for the chosen spatial resolution. ## Mitigation - Enforce a joint spatial-resolution x time-window minimum (finer space requires coarser time and vice versa) - Reject report requests that request both fine space and fine time simultaneously without additional suppression ## Affected conversions - Point → H3 - Raster → H3 --- # Resolution degradation policy > When a cell fails a privacy check, the correct response is to roll it up to a coarser resolution and re-check, not to drop it or leave it at the original resolution with a suppressed value, either of which loses coverage or gives a false sense of the original grain. - **URL:** https://etherdata.ai/blog/geo-interop-kb/edge-cases/resolution-degradation - **Category:** privacy ## Detection Any cell in the output failing the k-anonymity/DP check should trigger a resolution rollup; audit for cells that were dropped or suppressed-in-place instead. ## Mitigation - Implement automatic iterative rollup to h3ToParent until the privacy threshold is met - Document, per output row, the resolution actually reported versus the resolution originally requested ## Affected conversions - Admin polygons → H3 (weighted crosswalk) - Point → H3 - Raster → H3 --- # Regional privacy restrictions > Location-data privacy law varies by jurisdiction (GDPR/ePrivacy in the EU, CCPA/CPRA in California, and others), so a pipeline using a single global minimum-aggregation or consent policy can be non-compliant in stricter regions or needlessly conservative in others. - **URL:** https://etherdata.ai/blog/geo-interop-kb/edge-cases/regional-privacy-restrictions - **Category:** privacy ## Detection Pipeline configuration has one privacy policy constant with no per-jurisdiction override keyed to the data subject's region. ## Mitigation - Maintain a jurisdiction-keyed policy table (aggregation minimums, consent requirements, retention) rather than one global constant - Route each record's privacy treatment by its resolved jurisdiction, re-evaluating on any boundary or law change ## Affected conversions - Admin polygons → H3 (weighted crosswalk) - Point → H3 - Point+radius → H3 --- # Administrative boundary changes > Municipal annexations, incorporations, and county line adjustments change which admin polygon a location belongs to over time; a crosswalk built on one vintage misattributes cells for periods before or after the change. - **URL:** https://etherdata.ai/blog/geo-interop-kb/edge-cases/administrative-boundary-changes - **Category:** temporal ## Detection Compare admin polygon vintages across the reporting period; flag any cell whose admin assignment differs between the vintage in effect at event time and the vintage used for the join. ## Mitigation - Version every admin boundary crosswalk with validFrom/validTo dates - Join each event to the boundary vintage in effect on the event's own date, not the current vintage ## Affected conversions - Admin polygons → H3 (max-overlap partition) - Admin polygons → H3 (weighted crosswalk) --- # Temporary event zones > Ad-hoc geofences for a limited-duration event (a festival, stadium concert, disaster-response zone) exist only for a narrow time window; applying that geography outside its valid window either misses the event or wrongly attributes unrelated activity to it. - **URL:** https://etherdata.ai/blog/geo-interop-kb/edge-cases/temporary-event-zones - **Category:** temporal ## Detection Query time range extends beyond the event zone's declared start/end timestamps. ## Mitigation - Attach explicit start/end timestamps to every event-zone geometry and enforce them at query time - Auto-expire event geofences from active targeting/reporting configs after the event ends ## Affected conversions - Polygon → H3 (intersects) - Point+radius → H3 --- # Weather polygons > Weather alert/impact polygons (storm tracks, flood zones, air-quality advisories) are issued, updated, and retracted on an hourly-to-sub-hourly cadence; using a cached or stale version misrepresents current conditions for weather-triggered targeting or reporting. - **URL:** https://etherdata.ai/blog/geo-interop-kb/edge-cases/weather-polygons - **Category:** temporal ## Detection Polygon issued/valid timestamp older than the source's typical update cadence for that alert type. ## Mitigation - Re-fetch weather polygons on the vendor's native update cadence, never cache beyond it - Timestamp every cell-weather join with the polygon's issued time, not the query time ## Affected conversions - Polygon → H3 (intersects) - Raster → H3 --- # Store openings and closures > A store location list used for trade-area or catchment geometry goes stale as locations open, close, or relocate; targeting or measuring against a closed store's geometry wastes spend and biases lift measurement toward zero. - **URL:** https://etherdata.ai/blog/geo-interop-kb/edge-cases/store-openings-closures - **Category:** temporal ## Detection Store master list last-verified date exceeds the freshness SLA, or a store's activity signal (POS, footfall) drops to zero while still listed active. ## Mitigation - Refresh the store master against a POS or footfall feed on a fixed cadence, not just periodic manual audits - Auto-suspend targeting/measurement for any store with a sustained zero-activity signal pending verification ## Affected conversions - Admin polygons → H3 (weighted crosswalk) - Point+radius → H3 --- # Time-dependent audience membership > Audience membership defined by location visitation is inherently time-bound (a person who visited last month may not still be a customer); treating an audience segment as static after its build date silently stales the targeting/measurement population. - **URL:** https://etherdata.ai/blog/geo-interop-kb/edge-cases/time-dependent-audience - **Category:** temporal ## Detection Segment build/refresh timestamp older than the segment's documented validity window relative to current campaign flight dates. ## Mitigation - Attach a build timestamp and validity window to every audience segment and enforce refresh before expiry - Re-materialize location-based audiences on a fixed cadence rather than reuse a single static build across a long flight ## Affected conversions - Point → H3 - Point+radius → H3 --- # Version mismatch between execution and reporting > The H3/boundary/crosswalk version active at campaign execution time can differ from the version active when the reporting/measurement query runs later, causing the same nominal geography to resolve to different actual cells between planning and results. - **URL:** https://etherdata.ai/blog/geo-interop-kb/edge-cases/version-mismatch-execution-reporting - **Category:** temporal ## Detection Compare the crosswalk/boundary version identifier stamped at execution time against the version currently active when reporting queries run; flag any difference. ## Mitigation - Pin and log the exact crosswalk/boundary version used at execution time in the campaign record - Always run reporting/measurement joins against the pinned execution-time version, never the current-latest version ## Affected conversions - Admin polygons → H3 (weighted crosswalk) - H3 → platform-native IDs - Cell system → cell system