{
  "name": "Geographic Interoperability Knowledge Base",
  "site": "https://etherdata.ai/blog/geo-interop-kb",
  "pipeline": "source -> normalized -> canonical H3 -> executed -> reported/attributed",
  "conventions": {
    "crs": "EPSG:4326",
    "distances": "spherical haversine (m)",
    "areas": "m^2"
  },
  "pages": [
    {
      "title": "Administrative Boundaries",
      "slug": "administrative-boundaries",
      "category": "geometries",
      "summary": "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.",
      "source_geometry": [
        "admin_country",
        "admin_state",
        "admin_county",
        "postal_code",
        "census_geo",
        "dma"
      ],
      "status": "stable",
      "cell_systems": [
        "h3"
      ],
      "platforms": [
        "generic"
      ],
      "edge_cases": [
        "stale-boundaries",
        "duplicated-region-ids"
      ],
      "related": [
        "administrative-polygon-to-h3",
        "h3-to-administrative-crosswalk",
        "geometry-catalogue",
        "platform-identifiers"
      ],
      "badges": [],
      "last_reviewed": "2026-07-22",
      "destination_geometry": [],
      "url": "https://etherdata.ai/blog/geo-interop-kb/docs/administrative-boundaries",
      "markdownUrl": "https://etherdata.ai/geo-interop-kb/docs/administrative-boundaries.md",
      "markdown": "Administrative boundaries are the geometries that some authority — a\nnational mapping agency, a census bureau, a media-measurement vendor, a\nutility, a publisher — has declared to exist, and that authority's decision\nis the geometry's only source of truth. This distinguishes the family from\narbitrary polygons: a county line does not move because a better model\ndraws it differently, and disputing it means petitioning the agency, not\nre-running an algorithm. Every member of this family is consumed as a\n**partition** — reporting, budgeting, tax, and compliance logic downstream\nassumes every point on Earth belongs to exactly one instance of a given\nlevel, which is why the conversion path for this family (below) defaults to\nproducing a true partition rather than a proportional split.\n\n| | |\n|---|---|\n| Cardinality | Partition — every location belongs to exactly one instance per level |\n| Governed by | A national, statistical, or licensing authority per level (Census, USPS, Nielsen, ISO) |\n| Not a geometry | The id (FIPS, GEOID, DMA code, ZIP) — only the resolved boundary is |\n| Converts via | Center-contained or max-overlap polyfill into a cell-to-region partition |\n\n## Members\n\n| Member | What it is | Typical id / namespace |\n|---|---|---|\n| Country | National boundary | ISO 3166-1 alpha-2/3 |\n| State / province | First-level subdivision | FIPS (US), ISO 3166-2 |\n| County | Second-level subdivision | 5-digit FIPS (US) |\n| Municipality / borough | City, town, or borough-level unit | Local/vendor id, often no federal standard |\n| Postal code | USPS/national mail delivery area | ZIP (US), postcode (UK) |\n| Census geography | Tract, block group, block | GEOID (US Census/TIGER) |\n| DMA / media market | Nielsen-defined media market | DMA code (proprietary) |\n| NUTS or equivalent | EU statistical region hierarchy | NUTS 1/2/3 code |\n| Electoral district | Voting/representation boundary | State/national election-authority id |\n| School district | Education-service boundary | NCES district id (US) |\n| Service territory | Utility or franchise service area | Utility-internal id |\n| Publisher-defined market | A media owner's own named market | Publisher-internal label |\n\n## Required metadata\n\n| Field | Why it's required |\n|---|---|\n| Boundary source | Different agencies draw the \"same\" boundary differently (e.g. Census TIGER vs. a commercial vendor's county file) |\n| Vintage | Every level in this family is redrawn on its own schedule — counties rarely, DMAs and postal areas often |\n| CRS | Vendor shapefiles frequently arrive in a projected CRS and must be reprojected to EPSG:4326 before any H3 work |\n| Id namespace | \"12\" means nothing without knowing whether it's a FIPS state code, a DMA code, or a publisher's internal label |\n\n## Common risks\n\n> **Note:** `\"36061\"` is a string. It is not a shape until it is resolved against a\nspecific boundary file at a specific vintage. Two systems holding the same\nFIPS code can disagree about the polygon it resolves to if one is running a\n2020 vintage and the other a 2024 vintage — the id never changes even when\nthe boundary under it does. This is the single most common source of silent\nmisassignment in this family; see\n[requested vs. executed geography](/docs/requested-vs-executed-geography/).\n\nBeyond that structural risk, four failure modes recur across this family:\n**vintage drift** (DMA and postal boundaries move on cycles measured in\nyears, not decades — a crosswalk built against a stale vintage misassigns\nevery cell near a boundary that has since shifted); **leading-zero loss**\n(FIPS and GEOID strings — `\"01001\"` for Autauga County, Alabama — are\nnumeric-looking but not numbers, and an integer cast silently truncates the\nleading zero, producing a code that either doesn't match anything or\nmatches the wrong region); **ZIP-is-not-a-polygon** (a US ZIP code is a USPS\ndelivery-route abstraction with no authoritative boundary of its own — any\n\"ZIP polygon\" in circulation is a third party's ZCTA approximation, and\nmust be labeled and sourced as such rather than treated as ground truth);\nand **licensing** (DMA boundaries specifically are Nielsen's proprietary\nIP — redistributing a raw DMA shapefile, or a cell-to-DMA crosswalk derived\nfrom one, without the appropriate license is a legal exposure, not just a\ndata-quality one).\n\n## How it converts to H3\n\nAdministrative boundaries are polyfilled into H3 as a partition — one\nregion id per cell, by center-containment or max-overlap assignment — on\n[administrative polygon to H3](/docs/administrative-polygon-to-h3/). When\nthe requirement is proportional splitting instead (population or spend\ndivided across regions a cell straddles), use the weighted variant on\n[H3 to administrative crosswalk](/docs/h3-to-administrative-crosswalk/). If\nwhat you're actually holding is a bare id rather than a polygon, that is\nnot this family at all — see\n[platform identifiers](/docs/platform-identifiers/)."
    },
    {
      "title": "Administrative Polygon To H3",
      "slug": "administrative-polygon-to-h3",
      "category": "source-to-h3",
      "summary": "Converting counties, states, DMAs, census geographies, and postal areas into H3 cells while preserving the partition property those units are supposed to have.",
      "source_geometry": [
        "admin_county",
        "admin_state",
        "dma",
        "census_geo",
        "postal_code"
      ],
      "destination_geometry": [
        "h3_cell_set"
      ],
      "exactness": "approximate",
      "status": "stable",
      "cell_systems": [
        "h3"
      ],
      "platforms": [
        "generic"
      ],
      "edge_cases": [
        "stale-boundaries",
        "duplicated-region-ids",
        "multipart-geometries"
      ],
      "related": [
        "arbitrary-polygon-to-h3",
        "h3-to-administrative-crosswalk",
        "geometry-normalization",
        "mixed-h3-resolutions"
      ],
      "badges": [
        "Approximate"
      ],
      "last_reviewed": "2026-07-22",
      "url": "https://etherdata.ai/blog/geo-interop-kb/docs/administrative-polygon-to-h3",
      "markdownUrl": "https://etherdata.ai/geo-interop-kb/docs/administrative-polygon-to-h3.md",
      "markdown": "approximate\n\n## Purpose\n\nAdministrative and statistical boundaries — counties, states, DMAs, census\ntracts, ZCTAs, and postal-code areas — are usually consumed as a **partition**:\nevery H3 cell should belong to exactly one region, because reporting,\nbudgeting, and compliance logic downstream assumes non-overlapping buckets.\nThis page covers the conversion path that preserves that property, and\ncontrasts it with the retention-oriented crosswalk that does not.\n\n## Source geometry and destination geometry\n\nSource geometry is one of `admin_county`, `admin_state`, `dma`, `census_geo`,\nor `postal_code` — each supplied as a polygon or multipolygon in a vendor\nshapefile, GeoJSON file, or database geometry column, keyed by an identifier\n(FIPS, GEOID, DMA code, ZIP/ZCTA). Destination geometry is an\n`h3_cell_set`: a set of H3 cells at a stated resolution, each tagged with\nexactly one region id.\n\n## Exactness class\n\nThis conversion is **approximate**: no assignment rule reproduces the source\npolygon's area exactly, and the boundary of the assigned cell set will not\ncoincide with the source boundary at any resolution short of the coordinate\nprecision of the original survey. Two different, valid assignment rules\n(below) produce two different cell sets from the same input.\n\n## Containment rule and boundary behavior\n\nTwo assignment rules are in scope, and they answer different questions:\n\n| | |\n|---|---|\n| 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. |\n| 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. |\n\nCenter-contained is the default for reporting-grade partitions because it is\ndeterministic and reproducible from the polygon and the H3 grid alone,\nwithout needing an area computation per cell. Max-overlap is used when the\npolygon boundary runs close to many cell centers (common at res 8+ near\njagged county lines) and center-containment would otherwise assign a\ndisproportionate number of boundary cells to whichever side of the line the\ngrid happens to bias toward.\n\nNeither rule should be confused with the [weighted crosswalk](/docs/h3-to-administrative-crosswalk/),\nwhich deliberately breaks the partition property: it retains every\n`(cell_id, region_id)` pair a cell touches, with an intersection-area\nfraction per pair, so that population or spend can be split proportionally\nacross regions instead of forced into one.\n\n> Figure (admin-crosswalk): Max-overlap partition of two adjacent regions at R8; each cell to its argmax region (blue=west, pink=east).\n\n## Resolution behavior\n\nHigher resolution cells track the source boundary more closely because cell\narea shrinks roughly sevenfold per resolution step, shrinking the maximum\npossible per-cell disagreement between center-containment and the true\npolygon edge. At res 6, a single mis-assigned boundary cell can misplace\nseveral square kilometers; at res 9, the same error is bounded to a few\nhectares. Multipart admin units (a county with an offshore island, a DMA\nsplit by a lake) need per-part polyfilling — polyfilling the multipolygon as\na whole can silently drop small parts if the polyfill implementation does\nnot iterate rings.\n\n## Units and CRS\n\nSource polygons must be normalized to EPSG:4326 before polyfilling; areas\nfor overlap computation are computed as spherical (haversine-consistent) m².\nVendor shapefiles delivered in a projected CRS (state plane, Albers) must be\nreprojected first — reprojection error is typically under 1 meter for\nCONUS-scale admin polygons but should be checked, not assumed, for Alaska,\nHawaii, and territories.\n\n## Algorithm\n\n```ts\n\n// Partition: center-contained, one region per cell\nconst partitionCells = polygonToH3(countyPolygon, {\n  resolution: 8,\n  mode: \"center\",\n});\n\n// Partition by area majority, for boundary-heavy geographies\nconst majorityCells = maxOverlapAssignment(candidateCells, regionPolygons, {\n  resolution: 8,\n});\n```\n\nThe same conversion with the Python bindings (`h3-py` v4):\n\n```python\n\nfrom shapely.geometry import Polygon\n\n# Center-contained partition: one polygon_to_cells call per region\nregion_cells = {\n    region_id: h3.polygon_to_cells(h3.LatLngPoly(ring), res=8)\n    for region_id, ring in region_rings.items()  # ring = [(lat, lng), ...]\n}\n\n# Max-overlap assignment for cells straddling more than one region\nregion_shapely = {\n    region_id: Polygon([(lng, lat) for lat, lng in ring])\n    for region_id, ring in region_rings.items()\n}\ncandidate_cells = set().union(*region_cells.values())\n\nassignment = {}\nfor cell in candidate_cells:\n    boundary = Polygon([(lng, lat) for lat, lng in h3.cell_to_boundary(cell)])\n    best_region, best_area = None, 0.0\n    for region_id, poly in region_shapely.items():\n        area = boundary.intersection(poly).area\n        if area > best_area:\n            best_region, best_area = region_id, area\n    assignment[cell] = best_region  # argmax over intersection area\n```\n\nThe tested reference implementation for this conversion is the TypeScript\nin `lib/`; `h3-py` has no built-in `maxOverlapAssignment`, so the Python\nabove reproduces the same argmax-over-intersection-area loop with\n`shapely`.\n\n## Parameters\n\nResolution (int, typically 7–9 for county/DMA-scale work), assignment mode\n(`center` or `max-overlap`), boundary vintage (the effective date of the\nsource file), and the region id field to preserve.\n\n## Outputs\n\nA cell-to-region table: `cell_id`, `region_id`, `resolution`,\n`assignment_mode`, `boundary_vintage`. No overlap fraction is stored, because\nby construction each cell has exactly one region.\n\n## Quality metrics\n\nCompute `coverage_ratio` and `overreach_ratio` per region against the source\npolygon; a well-behaved center-contained partition typically shows\n`coverage_ratio` in the 0.90–0.98 range with `overreach_ratio` near zero,\nsince center-containment cannot assign a cell whose center lies outside the\npolygon. Check `jaccard` per region as a single combined figure for boundary\ntightness across resolutions.\n\n## Edge cases\n\nFIPS and GEOID codes are numeric strings with meaningful leading zeros\n(`\"01001\"` for Alabama, Autauga County); casting them to integers during a\njoin silently corrupts the key — this is the single most common cause of\n\"missing counties\" bugs. Boundary vintage matters: county lines are stable,\nbut DMA boundaries and ZCTA definitions change year over year\n([stale-boundaries](/docs/geometry-catalogue/)), so a cell-to-region table\nbuilt from a 2019 DMA file will misclassify cells near any boundary that\nmoved since. Some vendor files carry\n[duplicated-region-ids](/docs/geometry-catalogue/) — the same GEOID appearing\non two disjoint ring records for a single county, which is legal\nmultipart geometry, not a duplicate to be deduplicated away. And a ZIP code\nis not a polygon: it is a USPS delivery-route abstraction with no\nauthoritative boundary; any \"ZIP polygon\" in circulation is a third party's\nZCTA approximation and should be labeled and sourced as such rather than\ntreated as ground truth.\n\n## Assumptions and limitations\n\nThis conversion assumes the source file is a genuine partition of its\nparent geography (no gaps, no overlaps) before polyfilling — polyfilling\ncannot repair a source file that already double-counts territory. It also\nassumes callers need a partition; if the actual need is proportional\nweighting across regions, use the [arbitrary polygon](/docs/arbitrary-polygon-to-h3/)\nthreshold/weighted path instead of forcing a single-region assignment."
    },
    {
      "title": "Advertising Geographic Matching Semantics",
      "slug": "advertising-geographic-matching-semantics",
      "category": "advertising",
      "summary": "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.",
      "source_geometry": [
        "geofence",
        "point_radius",
        "trade_area",
        "device_ping"
      ],
      "destination_geometry": [
        "multipoint_audience"
      ],
      "status": "stable",
      "cell_systems": [
        "h3"
      ],
      "platforms": [
        "generic"
      ],
      "edge_cases": [
        "ip-derived-location",
        "consent-precision",
        "duplicate-observations"
      ],
      "related": [
        "requested-vs-executed-geography",
        "privacy-and-minimum-aggregation"
      ],
      "badges": [
        "Advertising"
      ],
      "last_reviewed": "2026-07-22",
      "url": "https://etherdata.ai/blog/geo-interop-kb/docs/advertising-geographic-matching-semantics",
      "markdownUrl": "https://etherdata.ai/geo-interop-kb/docs/advertising-geographic-matching-semantics.md",
      "markdown": "## The geometry is not the target\n\nTwo platforms can be handed the identical polygon — the same vertices, the\nsame CRS, the same H3 cell set — and deliver to materially different\naudiences. This is not a bug in one of the platforms; it is because a\ntargeting request is not fully specified by its geometry. It is specified by\na `GeographicTarget`: geometry plus a matching semantic, a lookback window, a\nlocation source, a confidence level, and whether the geometry includes or\nexcludes.\n\n| | |\n|---|---|\n| geometry | The shape itself — a polygon, circle, or H3 cell set. Necessary but not sufficient. |\n| matchingSemantic | How a device or person is associated with the shape. See the seven values below. |\n| lookbackWindow | How far back a presence event still counts as a match, e.g. 30 days, 90 days, or none (real time only). |\n| locationSource | GPS, IP, publisher-declared, cell tower, or a probabilistic model. Determines the effective precision, independent of the geometry's own precision. |\n| 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. |\n| inclusionOrExclusion | Whether the shape adds or removes eligibility. An excluded shape with weak matching leaks eligible devices back in. |\n\n## The seven matching semantics\n\n| | |\n|---|---|\n| 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. |\n| 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. |\n| 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. |\n| work_location | Same as home_location but for an inferred workplace cluster, typically daytime-weekday pings. |\n| 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. |\n| 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. |\n| 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. |\n\n> **Note:** A DSP set to `physical_presence` with a 0-day lookback and a social platform\nset to `presence_or_interest` with a 30-day lookback, both targeting the same\ncircle, are not comparable line items. One counts people who were there\ntoday; the other counts anyone who was there in the last month plus anyone\nwho merely expressed interest in the area. Reporting them under one\n\"targeted audience\" number is a category error, not a rounding difference.\n\n## Location source changes the effective geometry\n\n`locationSource` interacts with the geometry independent of the matching\nsemantic:\n\n- **GPS-derived**: typically 5–20 m accuracy outdoors, degrading indoors and\n  in urban canyons. The geometry is matched close to as-drawn.\n- **IP-derived**: resolves to an ISP allocation block, often centroid-biased\n  to a city or zip centroid rather than the device's true location — a\n  device can match a 1 km geofence from tens of kilometers away if the IP\n  registry entry is stale or the ISP routes traffic through a distant node.\n  See [ip-derived-location](/docs/coordinate-and-crs-failures/).\n- **Publisher-declared**: the location is asserted by the publisher (a\n  weather app's \"current city,\" a news site's regional edition) with no\n  device-level signal at all. Matches at the publisher's declared\n  granularity regardless of the requested geometry's precision.\n- **Cross-device**: a household or person graph links a matched device to\n  other devices never directly observed inside the geometry; the executed\n  audience is provably larger than the set of devices that produced a\n  location event.\n\n## Lookback window and duplicate eligibility\n\nA device observed inside overlapping geofences during the lookback window is\neligible under both. If reporting sums audience per fence rather than taking\nthe union, the same device is counted twice — this is\n`duplicate-observations`, and it inflates reach numbers proportional to\nfence overlap and lookback length. The mitigation is to deduplicate by a\nstable device key within the reporting window before aggregation, and to\nreport the union's cardinality, not the sum of per-fence counts.\n\n## Treatment/control contamination and excluded-area leakage\n\nFor measurement, not just delivery, matching semantics create contamination\npaths that geometry alone does not reveal:\n\n- A `recent_presence` semantic with a long lookback pulls people who moved\n  out of the control area into treatment eligibility retroactively, and vice\n  versa.\n- An excluded area (a competitor's exclusive zone, a control market) built\n  from `physical_presence` still leaks devices matched by `home_location` or\n  `interest`, because those semantics do not require the device to have been\n  physically inside the excluded polygon at all.\n- Bidstream truncation: real-time bidstream feeds frequently omit or coarsen\n  the location field below a documented threshold to save payload size or to\n  satisfy consent constraints, so a `physical_presence` match computed from\n  bidstream is silently biased toward the subset of impressions that still\n  carried usable coordinates.\n\n## Consent-based precision reduction\n\nConsent state (see [privacy-and-minimum-aggregation](/docs/privacy-and-minimum-aggregation/))\ncan force a platform to degrade `locationSource` precision or drop the\nlocation field entirely for a given user, without changing the requested\ngeometry. The safe behavior is to treat a consent-reduced observation as a\nlower-confidence match — never to up-sample it back to the original\ngeometry's resolution, and never to silently exclude it from reach\ndenominators without disclosure.\n\n## Assumptions and limitations\n\nThis model assumes the platform discloses `matchingSemantic` and\n`lookbackWindow` per line item; when a platform reports only\n`platform_defined`, downstream comparison across platforms is not valid\nwithout an independent audit, and any coverage or overlap metric computed\nfrom the geometry alone should be labeled as an upper bound on delivered\nmatch rate, not the match rate itself."
    },
    {
      "title": "Antimeridian Handling",
      "slug": "antimeridian-handling",
      "category": "systems",
      "summary": "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.",
      "source_geometry": [
        "geofence",
        "point_radius",
        "trade_area",
        "bounding_box"
      ],
      "destination_geometry": [
        "h3_cell_set"
      ],
      "exactness": "exact",
      "status": "stable",
      "cell_systems": [
        "h3"
      ],
      "platforms": [
        "generic"
      ],
      "edge_cases": [
        "antimeridian"
      ],
      "related": [
        "h3-to-exact-polygon",
        "bounding-box-to-h3",
        "geometry-normalization"
      ],
      "badges": [
        "Exact"
      ],
      "last_reviewed": "2026-07-22",
      "url": "https://etherdata.ai/blog/geo-interop-kb/docs/antimeridian-handling",
      "markdownUrl": "https://etherdata.ai/geo-interop-kb/docs/antimeridian-handling.md",
      "markdown": "exact\n\n## Purpose\n\nLongitude is a coordinate on a circle, not a line, and every operation here\nthat treats it as a line — ring winding, bounding-box construction, buffer\ngeneration, planar intersection — breaks silently for any geometry crossing\nthe ±180° meridian. This page states the exact detection test and exact\nmitigation so \"handle the antimeridian\" is never an unstated assumption.\n\n## The problem\n\nA polygon with vertices at longitude 179.5° and −179.8° is 0.7° wide across\nthe meridian, but a planar algorithm reading those numbers on a continuous\nline computes a span of 359.3° — effectively the entire globe minus a\nsliver. The same failure hits point-radius circles (a circle centered at\n179.9° with a 50 km radius has an arc crossing 180°) and bounding boxes (a\nbox whose \"west\" edge is numerically larger than its \"east\" edge, e.g.\nwest = 170°, east = −170°, is a valid wrapping box, not an invalid one).\n\n## Source geometry and destination geometry\n\nSource geometry is any polygon, point-radius circle, or bounding box\nnormalized to EPSG:4326 decimal degrees. Destination geometry is the same\ngeometry type, corrected, or an `h3_cell_set` if the corrected geometry is\nthen polyfilled. This page is a precondition for\n[geometry-normalization](/docs/geometry-normalization/), not an alternative\nto it — correction must run before self-intersection repair and before\npolyfilling, since polyfilling an unsplit, meridian-crossing ring returns\neither zero cells or every cell on the globe depending on which\nwinding-order edge case the library hits.\n\n## Exactness class\n\nExact. Correction is a coordinate-space transform, not an approximation:\nthe corrected geometry represents the identical physical region as the\nsource, with no coverage or area error introduced.\n\n## Detection\n\n| | |\n|---|---|\n| 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). |\n| 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. |\n| 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. |\n| 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. |\n\n## Mitigation\n\n| | |\n|---|---|\n| 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. |\n| 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. |\n| 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. |\n| 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. |\n\n## Resolution behavior\n\nCorrection is resolution-independent — a coordinate transform applied once,\nbefore polyfilling, whose correctness does not change with the H3\nresolution chosen downstream. What does change with resolution is the\nnumber of cells near the meridian whose own boundary crosses ±180°:\n`cellToBoundary` output for those cells needs the same split/unwrap logic\nbefore being rendered or intersected, since H3 cell boundaries are not\nautomatically corrected for meridian crossing by all consumers.\n\n## Units and CRS\n\nEPSG:4326 decimal degrees throughout. Splitting and densification run in\ngeodesic (great-circle) space, not a planar-projected CRS — projecting\nfirst would require a projection centered away from the crossing, which\nreintroduces the same problem at a different meridian.\n\n## Algorithm\n\n```ts\n\n// normalizePolygon detects span > 180deg and splits into a multipolygon\n// with each part's longitudes in a continuous, non-wrapping range.\nconst normalized = normalizePolygon(rawPolygon);\n\n// Polyfill each part independently; union the resulting cell sets.\nconst cells = normalized.parts.flatMap((part) =>\n  polygonToH3(part, { resolution: 8, mode: \"intersect\" }),\n);\n```\n\n## Parameters\n\nNone beyond the input geometry — a detection-and-correction pass, not a\ntunable conversion. The only implicit parameter is the densification sample\ncount used to insert intermediate vertices before computing the split\npoint.\n\n## Outputs\n\nA GeoJSON multipolygon (or corrected bounding box / circle) with no ring\nspanning more than 180° of raw longitude, ready for downstream normalization\nand polyfilling.\n\n## Quality metrics\n\n`coverage_ratio` and `jaccard` computed against the source, both of which\nshould equal 1.0 for a correct split (the correction is exact, so any\ndeviation indicates a bug in the split/densification, not an accepted\napproximation).\n\n## Edge cases\n\nAntimeridian is itself the edge case this page documents; it compounds with\n[self-intersections](/docs/geometry-normalization/) when a source polygon\nboth crosses the meridian and has invalid winding — splitting must run\nfirst, since repairing winding on an unsplit ring is undefined.\n\n## Assumptions and limitations\n\nThis page assumes the source geometry is otherwise valid GeoJSON — closed\nrings, no self-intersections away from the meridian. It does not address\npole-adjacent densification distortion at very high latitudes near the\ncrossing, best handled by increasing the sample count, not a different\nalgorithm.\n\n## Illustration — naive fill vs antimeridian split\n\n> 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)."
    },
    {
      "title": "Arbitrary Polygon To H3",
      "slug": "arbitrary-polygon-to-h3",
      "category": "source-to-h3",
      "summary": "The four containment modes for polyfilling any polygon into H3 cells, when to use each, and how to retain overlap for downstream weighting.",
      "source_geometry": [
        "trade_area",
        "geofence",
        "parcel"
      ],
      "destination_geometry": [
        "h3_cell_set"
      ],
      "exactness": "approximate",
      "status": "stable",
      "cell_systems": [
        "h3"
      ],
      "platforms": [
        "generic"
      ],
      "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"
      ],
      "badges": [
        "Approximate"
      ],
      "last_reviewed": "2026-07-22",
      "url": "https://etherdata.ai/blog/geo-interop-kb/docs/arbitrary-polygon-to-h3",
      "markdownUrl": "https://etherdata.ai/geo-interop-kb/docs/arbitrary-polygon-to-h3.md",
      "markdown": "approximate\n\n## Purpose\n\nTrade areas, geofences, and parcels arrive as free-form polygons with no\npartition guarantee and no administrative registry behind them. This is the\ngeneral-purpose polygon-to-H3 conversion: every other polygon conversion in\nthis knowledge base (administrative, buffered point-radius, corridor) reduces\nto this one after its own geometry-specific step. It exists in four\ncontainment modes because \"is this cell in the polygon\" has four different,\nequally legitimate answers.\n\n## Source geometry and destination geometry\n\nSource geometry is one of `trade_area`, `geofence`, or `parcel` — a single\npolygon or multipolygon, normalized ([geometry-normalization](/docs/geometry-normalization/))\nto EPSG:4326 with closed rings, correct winding, and repaired\nself-intersections before polyfilling. Destination geometry is an\n`h3_cell_set`, optionally paired with per-cell overlap fractions.\n\n## Exactness class\n\nApproximate, by design and by mode: `full` and `intersect` are conservative\nand expansive respectively at the two ends of a spectrum, `center` is the\ncheapest and least biased single answer, and `threshold` is a tunable\napproximation with no single \"correct\" cutoff.\n\n## Containment rule and boundary behavior\n\n| | |\n|---|---|\n| 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. |\n| 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. |\n| 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. |\n| 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. |\n\n```\ncenter:      o--o--o--o        full:       [--][--]\n             | polygon |                   | polygon |\n             o--o--o--o                    [--][--]\n             (cell IN if its o is inside)  (cell IN only if fully inside)\n\nintersect:  [##][##][##]       threshold:  [##][--][xx]\n            | polygon |                    | polygon |  (xx below t, dropped)\n            [##][##][##]                   [##][ >=t ][xx]\n            (any touch => IN)              (area fraction >= t => IN)\n```\n\nThe threshold rule is the only one with a closed-form test per cell:\n\n$$\n\\frac{\\text{intersection area}(cell, \\text{polygon})}{\\text{cell area}} \\ge t\n$$\n\nA common convention is `t = 0.5`, which behaves like a tie-break between\n`full` and `intersect`, but `t` is a caller parameter, not a constant — a\ncompliance use case that must never overreach should push `t` toward 1.0\n(converging on `full`), while a reach-maximizing use case should push it\ntoward a small positive value (converging on `intersect` but excluding\ncells that only graze the boundary at a single point).\n\n> Figure: center · 89.9% coverage, 9% overreach\n> Figure: full · 49.4% coverage, 0% overreach\n> Figure: intersect · 100% coverage, 68% overreach\n> Figure: threshold t=0.5 · 20 cells\n\n## Resolution behavior\n\nAll four modes converge toward the true polygon area as resolution\nincreases, because the maximum per-cell area error shrinks with cell size.\n`full` converges from below (`coverage_ratio` rising toward 1),\n`intersect` converges from above (`overreach_ratio` falling toward 0), and\n`center` and `threshold` oscillate around the true value with shrinking\namplitude. For polygons smaller than a handful of cells at the chosen\nresolution, none of the modes converge usefully — see\n[tiny-polygons](/docs/geometry-catalogue/) below.\n\n## Units and CRS\n\nEPSG:4326 input, normalized before polyfilling. Intersection and cell areas\nfor `threshold` and for quality metrics are computed as spherical m²\n(haversine-consistent), not planar-projected m² — projecting to a local\nCartesian frame before an area computation introduces its own distortion\nthat should be measured, not assumed away, especially above 60° latitude.\n\n## Algorithm\n\n```ts\n\nconst conservative = polygonToH3(tradeArea, { resolution: 9, mode: \"full\" });\nconst expansive = polygonToH3(tradeArea, { resolution: 9, mode: \"intersect\" });\nconst tuned = polygonToH3(tradeArea, {\n  resolution: 9,\n  mode: \"threshold\",\n  threshold: 0.5,\n});\n\n// Partition assignment when a cell could belong to more than one polygon\nconst assigned = maxOverlapAssignment(tuned, [tradeAreaA, tradeAreaB], {\n  resolution: 9,\n});\n\n// Retention-oriented crosswalk: keep every touched region per cell\nconst crosswalk = weightedCrosswalk(tradeArea, { resolution: 9 });\n```\n\nThe same conversion with the Python bindings (`h3-py` v4):\n\n```python\n\nfrom shapely.geometry import Polygon, shape\n\n# center mode: h3-py's polygon_to_cells is center-containment only\nouter_ring = [(lat, lng) for lng, lat in trade_area_coords]  # h3-py wants (lat, lng)\npoly = h3.LatLngPoly(outer_ring)\ncenter_cells = h3.polygon_to_cells(poly, res=9)\n\n# full / intersect / threshold: h3-py has no mode argument, so classify\n# each candidate cell's overlap area against the polygon ourselves.\nshapely_poly = Polygon([(lng, lat) for lat, lng in outer_ring])\ncandidates = h3.polygon_to_cells(poly, res=9) | {\n    n for c in center_cells for n in h3.grid_disk(c, 1)\n}\n\ndef classify(cell, mode, threshold=0.5):\n    boundary = Polygon([(lng, lat) for lat, lng in h3.cell_to_boundary(cell)])\n    inter = boundary.intersection(shapely_poly).area\n    if mode == \"full\":\n        return inter == boundary.area\n    if mode == \"intersect\":\n        return inter > 0\n    if mode == \"threshold\":\n        return (inter / boundary.area) >= threshold\n\nfull_cells = {c for c in candidates if classify(c, \"full\")}\nintersect_cells = {c for c in candidates if classify(c, \"intersect\")}\ntuned_cells = {c for c in candidates if classify(c, \"threshold\", 0.5)}\n```\n\nThe tested reference implementation for this conversion is the TypeScript\nin `lib/`; the Python above reproduces the same per-cell decisions with\ncore `h3-py` calls plus `shapely` for area, since `h3-py` (like `h3-js`)\nonly ships center containment natively.\n\n## Parameters\n\nResolution, containment mode, threshold `t` (mode `threshold` only), and\nwhether output should be a partition (via `maxOverlapAssignment`) or a\nretained multi-region crosswalk (via `weightedCrosswalk`).\n\n## Outputs\n\nFor the four containment modes: a flat `h3_cell_set` at the stated\nresolution and mode. For `weightedCrosswalk`, one row per touched\n`(cell, region)` pair carrying `cell_id`, `source_region_id`,\n`intersection_area`, `cell_coverage_fraction` (intersection area over cell\narea), and `region_coverage_fraction` (intersection area over the source\npolygon's total area) — the fields needed to split a metric like population\nor spend proportionally across regions rather than assigning it to one.\n\n## Quality metrics\n\nReport `coverage_ratio`, `overreach_ratio`, `underreach_ratio`, and\n`jaccard` per polygon per mode. `full` should show `overreach_ratio = 0` by\nconstruction; `intersect` should show `underreach_ratio = 0` by\nconstruction — if either is violated, the polyfill implementation has a bug,\nnot the polygon.\n\n## Edge cases\n\nNarrow polygons (a road-adjacent strip, a thin trade-area sliver) can be\nnarrower than a cell's diameter at coarse resolutions, causing `full` to\nreturn zero cells while `center` and `intersect` still return a thin\none-cell-wide line — always sanity-check `full` output is non-empty before\ntrusting it downstream. Tiny polygons (sub-cell-area parcels) hit the same\nfailure for `full` and produce a single, disproportionately large cell for\n`center`; `threshold` with a low `t` is usually the least-bad default here.\nTouching-only polygons (two trade areas that share a boundary but do not\noverlap) can still both claim the same boundary cell under `intersect`,\nwhich is correct per the rule but must be resolved with\n`maxOverlapAssignment` if a partition is required. Simplified boundaries\n(Douglas-Peucker-reduced trade areas from a mapping SDK) shift the true\nedge by the simplification tolerance, which should be recorded and treated\nas an additional uncertainty band on top of the containment mode's own\nerror. Holes (a trade area with an excluded interior ring, e.g. a\ncompetitor's exclusive zone) must be respected by the polyfill\nimplementation — a naive polyfill that ignores interior rings will silently\ninclude cells the source explicitly excludes.\n\n## Assumptions and limitations\n\nThis conversion assumes the input polygon has already been normalized —\nself-intersections repaired, rings closed and correctly wound — since\npolyfilling a malformed polygon produces an undefined or silently wrong cell\nset rather than an error. It also assumes callers know which of the four\nmodes they need before running the conversion: switching modes after the\nfact on an already-polyfilled cell set is not possible without re-running\nagainst the source polygon."
    },
    {
      "title": "Arbitrary Polygons",
      "slug": "arbitrary-polygons",
      "category": "geometries",
      "summary": "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.",
      "source_geometry": [
        "trade_area",
        "geofence",
        "parcel"
      ],
      "status": "stable",
      "cell_systems": [
        "h3"
      ],
      "platforms": [
        "generic"
      ],
      "edge_cases": [
        "self-intersections",
        "holes",
        "narrow-polygons",
        "tiny-polygons",
        "simplified-boundaries"
      ],
      "related": [
        "arbitrary-polygon-to-h3",
        "geometry-normalization",
        "administrative-boundaries",
        "point-radius-geometries"
      ],
      "badges": [],
      "last_reviewed": "2026-07-22",
      "destination_geometry": [],
      "url": "https://etherdata.ai/blog/geo-interop-kb/docs/arbitrary-polygons",
      "markdownUrl": "https://etherdata.ai/geo-interop-kb/docs/arbitrary-polygons.md",
      "markdown": "Arbitrary polygons are shapes with no external authority: nobody can look\nup \"the\" boundary the way a county line can be looked up in a TIGER file,\nbecause the boundary was drawn — by a human digitizing a delivery zone, by\na drive-time model, by a customer-catchment algorithm — for a specific\npurpose and exists only in the system that drew it. This is the defining\ndifference from [administrative boundaries](/docs/administrative-boundaries/):\nthere is no vintage to reconcile against, because there is no canonical\nversion to reconcile with. The tradeoff is flexibility for verifiability —\nan arbitrary polygon can be regenerated at will, but nothing external\nconfirms it is correct.\n\n| | |\n|---|---|\n| Cardinality | No partition guarantee — polygons may overlap or leave gaps by design |\n| Governed by | Whoever drew or generated it — no external authority to check against |\n| Not a geometry | A parcel APN or territory label — the shape lives in the polygon file, not the code |\n| Converts via | Repair (self-intersection, holes) then polyfill under center/full/intersect/threshold |\n\n## Members\n\n| Member | What it is | Typical generation method |\n|---|---|---|\n| Trade area | A store's modeled customer catchment | Drive-time isochrone or gravity model |\n| Delivery area | A courier or fulfillment service boundary | Manual digitization or routing-engine output |\n| Geofence | A presence-targeting or measurement polygon | Manual digitization (KML/GeoJSON) |\n| Venue campus | A stadium, mall, or airport footprint | Manual digitization from imagery |\n| Store catchment | Observed or modeled visit-origin area | Visit-data clustering or isochrone |\n| Parcel | A legal land boundary | Assessor / cadastre survey |\n| Regulatory zone | A jurisdiction-drawn compliance area (e.g. emissions zone) | Regulatory-body digitization |\n| Weather polygon | A storm warning or advisory area | Meteorological model output |\n| Custom sales territory | An internally defined rep or region boundary | Manual assignment, often built from admin unions |\n| Exclusion zone | An area explicitly carved out of a target set | Manual digitization or set subtraction |\n\n## Required metadata\n\n| Field | Why it's required |\n|---|---|\n| Generation method | Determines how much to trust the boundary — a surveyed parcel and a hand-drawn geofence carry very different confidence |\n| 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 |\n| CRS | Especially important for parcel data from assessors, which is frequently delivered in a state-plane or other projected CRS |\n| Author / purpose | A geofence drawn for measurement and one drawn for targeting may look identical but license and privacy handling differ |\n\n## Common risks\n\nBecause nothing external validates an arbitrary polygon, geometric defects\nthat a surveyed boundary would never contain show up routinely here:\n**self-intersection** (bowtie rings, most often from manual digitization or\na model artifact, which make area and containment undefined until\nrepaired — see [geometry normalization](/docs/geometry-normalization/));\n**holes** (an interior ring — a courtyard carved out of a campus polygon, a\ncompetitor's parcel excluded from a territory — that must be passed through\nto the filler intact, not silently filled over); **narrow slivers and tiny\npolygons** (a delivery area a few hundred meters wide, or a parcel smaller\nthan a single cell at the working resolution, can legitimately produce zero\ncenter-contained cells, which is a correct result of the containment rule,\nnot a bug); and **simplification drift** (a boundary run through\nDouglas-Peucker-style simplification for file-size reasons shifts vertices\nenough to change which cells qualify, especially at higher resolutions\nwhere cell edges are shorter than the simplification tolerance).\n\nA parcel's assessor identifier (an APN) is worth flagging on its own: it\nidentifies a legal record, and the polygon it's joined to may lag a\nsubdivision, merge, or boundary adjustment recorded at the assessor but not\nyet reflected in the geometry file — the id and the shape can drift apart\neven within this family, in the same way an administrative id can outlive\nits boundary.\n\n## How it converts to H3\n\nAll ten members above share one conversion path: buffer or otherwise\nnormalize the polygon, then polyfill under `center`, `full`, `intersect`, or\n`threshold` containment. See\n[arbitrary polygon to H3](/docs/arbitrary-polygon-to-h3/) for the full\ncontainment-mode comparison and the repair steps for self-intersections and\nholes. A shape that is described as a fixed radius around a point — \"3 miles\naround this store\" — is not an arbitrary polygon even if a system stores it\nas one; it belongs to\n[point-radius geometries](/docs/point-radius-geometries/), and forcing it\nthrough this path discards the radius-units question that family forces\nexplicitly."
    },
    {
      "title": "Bounding Box To H3",
      "slug": "bounding-box-to-h3",
      "category": "source-to-h3",
      "summary": "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.",
      "source_geometry": [
        "bounding_box"
      ],
      "destination_geometry": [
        "h3_cell_set"
      ],
      "exactness": "approximate",
      "status": "stable",
      "cell_systems": [
        "h3"
      ],
      "platforms": [
        "generic"
      ],
      "edge_cases": [
        "antimeridian"
      ],
      "related": [
        "antimeridian-handling",
        "geometry-normalization",
        "arbitrary-polygon-to-h3"
      ],
      "badges": [
        "Approximate"
      ],
      "last_reviewed": "2026-07-22",
      "url": "https://etherdata.ai/blog/geo-interop-kb/docs/bounding-box-to-h3",
      "markdownUrl": "https://etherdata.ai/geo-interop-kb/docs/bounding-box-to-h3.md",
      "markdown": "approximate\n\n## Purpose\n\nA bounding box — `west`, `south`, `east`, `north` — is not itself a\npolygon; it is four numbers that imply one only under an assumed\nconvention (geographic coordinates, `west < east`, no wraparound). Treating\nthose four numbers as directly polyfillable without normalizing them first\nis the single most common source of silently wrong bounding-box conversions,\nparticularly for any box that crosses the antimeridian.\n\n## Source geometry and destination geometry\n\nSource geometry is `bounding_box`: a `(west, south, east, north)` tuple,\ntypically produced by a map viewport, a vendor's coarse \"service area,\" or\na quick-and-dirty spatial filter. Destination geometry is an `h3_cell_set`\nafter the box is converted to a proper polygon and polyfilled by the\n[arbitrary polygon](/docs/arbitrary-polygon-to-h3/) machinery.\n\n## Exactness class\n\nApproximate, inheriting the exactness class of whichever polygon\ncontainment mode is applied after normalization — a bounding box carries no\ninformation about the \"real\" shape it is meant to approximate, so it should\nbe treated as a crude proxy geometry, not a precise target, in any use case\nwhere the box is standing in for something else (a service area, a rough\nmarket boundary).\n\n## Containment rule and boundary behavior\n\nConversion is two steps, and the first step is where correctness is\nactually decided:\n\n1. **Normalize**: turn `(west, south, east, north)` into a closed,\n   correctly wound polygon in EPSG:4326. This requires resolving three\n   ambiguities before a polygon can be constructed at all — coordinate\n   validity, west/east ordering, and antimeridian crossing (below).\n2. **Polyfill**: apply any of the four standard containment modes\n   (`center`, `full`, `intersect`, `threshold`) to the normalized polygon,\n   exactly as on the [arbitrary polygon](/docs/arbitrary-polygon-to-h3/)\n   page.\n\nNormalization must check, in order: that `south < north` (a box with\n`south > north` is invalid, not merely reversed, since latitude does not\nwrap); whether `west > east` in a way that indicates antimeridian crossing\nrather than an invalid box (see edge cases); and that all four values fall\nwithin valid ranges (latitude in `[-90, 90]`, longitude in `[-180, 180]`)\nbefore any polygon is built. A rotated bounding box — one expressed in\nscreen-space (pixel or tile) coordinates rather than geographic ones — is\nnot a case this conversion handles at all; it must first be reprojected to\ngeographic west/south/east/north, since a box that is axis-aligned on\nscreen is not axis-aligned on the geographic grid except at very coarse\nzoom levels.\n\n## Resolution behavior\n\nOrdinary polyfill resolution behavior applies once the box is a normalized\npolygon: the chosen containment mode's coverage error shrinks with finer\nresolution exactly as for any rectangular polygon. There is no\nbox-specific resolution consideration beyond noting that a bounding box is\nusually a much coarser proxy for the caller's actual target than the\nresolution suggests — polyfilling a viewport bounding box at res 10 does\nnot make the box a more accurate representation of anything, it just\nproduces a finer-grained approximation of a shape that was already an\napproximation.\n\n## Units and CRS\n\nInput coordinates must be EPSG:4326 decimal degrees. A box supplied in\nWeb Mercator (EPSG:3857) tile bounds — common from map-viewport APIs —\nmust be reprojected to geographic coordinates before normalization; failing\nto do so produces a box with plausible-looking but wrong degree values,\nespecially near the poles where Web Mercator's distortion is most severe.\n\n## Algorithm\n\n```ts\n\nfunction bboxToH3(west, south, east, north, resolution, mode) {\n  if (south > north) {\n    throw new Error(\"invalid box: south must be <= north\");\n  }\n  // west > east signals antimeridian crossing, not an invalid box;\n  // normalizePolygon must split the resulting polygon at +/-180.\n  const rawPolygon = boxToPolygon(west, south, east, north);\n  const polygon = normalizePolygon(rawPolygon); // splits at antimeridian if needed\n  return polygonToH3(polygon, { resolution, mode });\n}\n```\n\nThe same conversion with the Python bindings (`h3-py` v4):\n\n```python\n\ndef bbox_to_h3(west, south, east, north, res):\n    if south > north:\n        raise ValueError(\"invalid box: south must be <= north\")\n\n    if west <= east:\n        # Ordinary box: one LatLngPoly, counter-clockwise ring\n        ring = [(south, west), (south, east), (north, east), (north, west)]\n        return set(h3.polygon_to_cells(h3.LatLngPoly(ring), res))\n\n    # west > east signals antimeridian crossing, not an invalid box: split\n    # into two boxes at +/-180 and union the polyfilled results.\n    west_ring = [(south, west), (south, 180), (north, 180), (north, west)]\n    east_ring = [(south, -180), (south, east), (north, east), (north, -180)]\n    west_cells = h3.polygon_to_cells(h3.LatLngPoly(west_ring), res)\n    east_cells = h3.polygon_to_cells(h3.LatLngPoly(east_ring), res)\n    return set(west_cells) | set(east_cells)\n\ncells = bbox_to_h3(170, 10, -170, 20, res=6)  # antimeridian-crossing box\n```\n\n`h3.polygon_to_cells` is center containment only, matching the `center`\nmode above; `full`/`intersect`/`threshold` need the same per-cell\n`shapely` classification described on the\n[arbitrary polygon](/docs/arbitrary-polygon-to-h3/) page, applied to\nwhichever normalized (and, if needed, antimeridian-split) polygon results\nfrom `bbox_to_h3`'s normalization step. The tested reference\nimplementation for this conversion is the TypeScript in `lib/`.\n\n## Parameters\n\nThe four box coordinates, H3 resolution, containment mode, and an explicit\n`assumeAntimeridianCrossing` flag or equivalent so that `west > east` is\nhandled deliberately rather than inferred silently from the sign of the\ndifference alone.\n\n## Outputs\n\nAn `h3_cell_set` at the stated resolution and mode; for a box that crosses\nthe antimeridian, the output correctly includes cells on both the\neasternmost and westernmost sides of the +/-180 meridian rather than\neither an empty result or a result covering the wrong (much larger)\nhemisphere.\n\n## Quality metrics\n\n`coverage_ratio`, `overreach_ratio`, and `jaccard` computed against the\nnormalized polygon, not against the raw four-number box — since the box has\nno area itself, these metrics are only meaningful once it has been turned\ninto a polygon. A useful box-specific diagnostic is polygon area versus the\nnaive `(east - west) * (north - south)` product: a large discrepancy is a\nsignal that antimeridian handling or high-latitude distortion is present\nand should be checked.\n\n## Edge cases\n\nAntimeridian crossing is the dominant failure mode for this conversion. A\nbox describing, for example, the Pacific region spanning `west = 170` to\n`east = -170` has `west > east` in raw numeric terms, but this is a valid\n20-degree-wide box that crosses +/-180, not an inverted or invalid one; a\nnaive implementation that assumes `west < east` will either construct a\npolygon spanning the wrong 340-degree remainder of the globe or throw on\n`south/north`-style validation logic misapplied to longitude. Correct\nhandling splits the box into two polygons — one from `west` to `180`, one\nfrom `-180` to `east` — polyfills each independently, and unions the\nresulting cell sets; see [antimeridian handling](/docs/antimeridian-handling/)\nfor the general treatment this conversion depends on. Distinguishing a\ngenuinely invalid box (`west > east` due to a data error, with no crossing\nintended) from a legitimate antimeridian-crossing box requires either an\nexplicit caller flag or a heuristic threshold (e.g. only treat `west > east`\nas crossing if the implied \"short way\" span is under some maximum width),\nand that heuristic should be documented wherever it is applied rather than\nleft implicit, since it is a business-logic assumption, not a geometric\nfact.\n\n## Assumptions and limitations\n\nThis conversion assumes the four input numbers are genuinely geographic\nwest/south/east/north bounds in EPSG:4326; screen-space, tile-space, or\nrotated bounding regions must be converted to that form first, and no\namount of careful antimeridian handling here corrects for a box that was\nnever geographic to begin with. It also assumes a normalized polygon is\nproduced and validated before polyfilling — skipping normalization \"because\nthe box looks fine\" is exactly the failure mode that antimeridian-crossing\nboxes exploit, since they look fine as four numbers and only fail once\nnaively converted to a polygon.\n\n## Illustration\n\n> Figure (bounding-box): A [W,S,E,N] envelope normalized to a polygon, then intersect-filled at R9."
    },
    {
      "title": "Bounding Boxes",
      "slug": "bounding-boxes",
      "category": "geometries",
      "summary": "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.",
      "source_geometry": [
        "bounding_box"
      ],
      "status": "stable",
      "cell_systems": [
        "h3"
      ],
      "platforms": [
        "generic"
      ],
      "edge_cases": [
        "antimeridian"
      ],
      "related": [
        "bounding-box-to-h3",
        "antimeridian-handling",
        "arbitrary-polygons",
        "coordinate-and-crs-failures"
      ],
      "badges": [],
      "last_reviewed": "2026-07-22",
      "destination_geometry": [],
      "url": "https://etherdata.ai/blog/geo-interop-kb/docs/bounding-boxes",
      "markdownUrl": "https://etherdata.ai/geo-interop-kb/docs/bounding-boxes.md",
      "markdown": "A bounding box is an envelope — `[west, south, east, north]` — that\ndescribes the extent of a map viewport or a query's search bounds. It is\nnot a shape describing any real place: nothing about a store's coverage or\na region's outline is naturally rectangular, and a bounding box used as a\ntargeting or reporting geometry is almost always a stand-in for\n\"everything visible on screen right now\" or \"everything the caller asked to\nsearch within,\" not a deliberate footprint. Treating a bounding box as if\nit carries the same intent as a drawn polygon is the family's core\nconceptual risk; treating it as a trivial four-number rectangle with no\nedge cases is the family's core implementation risk.\n\n| | |\n|---|---|\n| Cardinality | One rectangle — never a partition, never a real footprint |\n| Governed by | Whatever client or query produced it — a viewport state or search parameter |\n| Not a geometry | A saved market label attached to a live, constantly-changing viewport rectangle |\n| Converts via | Corner-to-polygon construction (antimeridian-aware), then polyfill |\n\n## Members\n\n| Member | What it bounds | Typical origin |\n|---|---|---|\n| Map viewport | The currently visible map extent | Client-side map state (pan/zoom) |\n| Search bounds | A geocoder or places-API query restriction | API request parameter |\n| API query envelope | A spatial filter on a data query | Query parameter |\n| Market extent | A rough rectangular stand-in for a market's footprint | Manually specified, often for a quick estimate |\n\n## Required metadata\n\n| Field | Why it's required |\n|---|---|\n| CRS | Standard EPSG:4326 assumption, but verify — some mapping SDKs report viewport bounds in Web Mercator |\n| 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 |\n| 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 |\n\n## Common risks\n\n**Antimeridian crossing**: when a box crosses ±180° longitude, `west` is\nnumerically greater than `east` (e.g. `west=170, east=-170` for a box\nspanning the ±180° line near Fiji) — a naive box-to-polygon conversion that\nassumes `west < east` either produces an inverted rectangle or, worse, a\nbox that silently spans the entire globe the wrong way. This is common\nenough near the Pacific that any bounding-box conversion needs an explicit\nantimeridian check, not an assumption that it won't happen; see\n[antimeridian handling](/docs/antimeridian-handling/). **Rotated boxes**:\nsome viewport-derived bounds are not axis-aligned (a rotated map view), and\na `[west, south, east, north]` tuple cannot represent rotation at all —\nif the source system supports rotation, the bounding box handed to a\nconversion may already be a lossy axis-aligned approximation of the true\nviewport, and that loss should be disclosed rather than treated as exact.\n**Screen vs. geographic conflation**: a screen/viewport rectangle changes\nwith every pan and zoom and is not a stable geometry to persist or report\nagainst, while a geographic envelope (a market extent, a search radius\nexpressed as a box) is meant to be stable; using a live viewport bound as\nif it were a saved market definition silently changes the \"region\" every\ntime a user's map state changes. **West/east ordering assumptions**:\ndistinct from the antimeridian case, some upstream systems deliver bounds\nalready reordered (`min`/`max` rather than `west`/`east`), and assuming the\ntuple order matches the field names without checking produces a box that\nis numerically valid but geographically wrong.\n\n> **Note:** A market extent expressed as a bounding box is a rectangle that happens to\ncontain the market, not a shape describing it — it will always include\nterritory the market doesn't actually cover. Where a real market footprint\nis needed rather than a quick rectangular estimate, use\n[arbitrary polygons](/docs/arbitrary-polygons/) or\n[administrative boundaries](/docs/administrative-boundaries/) instead.\n\n## How it converts to H3\n\nA bounding box converts to H3 by first constructing a four-vertex polygon\nfrom its corners — handling the antimeridian sign flip explicitly — and\nthen polyfilling that polygon under the same containment modes used for\nany other polygon. See [bounding box to H3](/docs/bounding-box-to-h3/) for\nthe corner-construction algorithm and the antimeridian-safe splitting\nlogic, and [coordinate and CRS failures](/docs/coordinate-and-crs-failures/)\nfor the broader class of ordering and projection mistakes that a bounding\nbox is especially prone to surfacing, since a four-number rectangle offers\nno redundancy to catch a transposed coordinate the way a denser polygon\nring sometimes does."
    },
    {
      "title": "Cell System Comparison",
      "slug": "cell-system-comparison",
      "category": "systems",
      "summary": "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.",
      "source_geometry": [
        "h3_cell_set"
      ],
      "destination_geometry": [
        "h3_cell_set"
      ],
      "exactness": "approximate",
      "status": "stable",
      "cell_systems": [
        "h3",
        "s2",
        "geohash"
      ],
      "platforms": [
        "generic"
      ],
      "edge_cases": [
        "pentagons",
        "mixed-resolutions"
      ],
      "related": [
        "h3-overview",
        "h3-pentagons",
        "mixed-h3-resolutions"
      ],
      "badges": [
        "Approximate"
      ],
      "last_reviewed": "2026-07-22",
      "url": "https://etherdata.ai/blog/geo-interop-kb/docs/cell-system-comparison",
      "markdownUrl": "https://etherdata.ai/geo-interop-kb/docs/cell-system-comparison.md",
      "markdown": "approximate\n\n## Purpose\n\nThis knowledge base treats H3 as the canonical interchange grid, but not\nevery input or platform is H3-native, and the generic documentation model\nmust not silently assume properties that are true of H3 but false of the\nother discrete global grid systems in use elsewhere in the ecosystem. This\npage is the comparison matrix and the explicit statement of what must never\nbe assumed generically across systems.\n\n> **Note:** The AdCP (Ad Context Protocol) committee has accepted three cell systems as\ninteroperable options: H3, S2, and quadkeys. This knowledge base recommends\nH3 as the default and treats it as the canonical interchange grid\nthroughout; S2 and Geohash are documented here for contrast, not as\nalternates in active use. H3's hexagonal cells give every non-pentagon cell\na uniform distance to its six neighbours and compact well into multi-\nresolution sets, which is why H3 rather than a quadrilateral or rectangular\nscheme is the standard.\n\n## Comparison matrix\n\n| Dimension | H3 | S2 | Geohash |\n|---|---|---|---|\n| Cell shape | Hexagon (mostly), 12 pentagons per resolution | Quadrilateral | Rectangle |\n| 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 |\n| Children per parent | ~7 (not exact by area) | Exactly 4 | 32 |\n| Equal-area | Approximate (varies ~2x globally) | No | No (shrinks toward poles) |\n| 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 |\n| Global coverage | Yes | Yes | Yes |\n| Index representation | 64-bit index, hex string | 64-bit cell id (Hilbert curve position) | Base-32 string |\n| Compaction support | Yes (`compactCells`/`uncompactCells`) | Yes | No |\n| 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 |\n\n## What the generic model must not assume\n\n> **Note:** Every one of these is true of H3 specifically and false of at least one\nother system in this table. Code, prose, or a schema field that encodes any\nof these as a general \"cell system\" property rather than an H3-specific one\nis a latent bug the first time a non-H3 system reaches it.\n\n| | |\n|---|---|\n| Hexagons | Only H3 has hexagon-dominant cells, and even H3 is not all-hexagon. S2 is quadrilateral, Geohash is rectangular. |\n| Six neighbours | True only for non-pentagon H3 cells. S2 cells have 4 edge neighbours (more at corners); Geohash rectangles have 4. |\n| 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.' |\n| 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. |\n| Identical hierarchy semantics | 'Hierarchical' does not imply the same containment guarantee. S2 and Geohash give exact geometric/prefix containment; H3 gives logical-only containment. |\n| 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. |\n\n## Cross-system conversion has no direct cell-to-cell map\n\nThere is no lookup table mapping an H3 cell index directly to an S2 or\nGeohash index, because the three systems tile the sphere with different\ngeometries at different resolutions — no cell boundary in one aligns\nexactly with any boundary in another. The only correct conversion path is\ngeometric, not index-based:\n\n$$\n\\text{cells}_A \\xrightarrow{\\text{cellToBoundary (system A)}} \\text{polygon}\n\\xrightarrow{\\text{union}} \\text{region}\n\\xrightarrow{\\text{polyfill (system B)}} \\text{cells}_B\n$$\n\n1. Extract each source cell's true boundary polygon in the source system\n   (`cellToBoundary` or the equivalent for S2/Geohash).\n2. Union the boundary polygons into a single region (or multipolygon, with\n   antimeridian handling per\n   [antimeridian-handling](/docs/antimeridian-handling/) if applicable).\n3. Re-fill that region into the destination system using the destination\n   system's own polyfill operation and containment mode (`center`, `full`,\n   `intersect`, or the destination system's equivalent).\n\nThis path necessarily introduces the same containment-mode approximation\nerror documented throughout the source-to-H3 conversion pages, applied a\nsecond time if round-tripping — a cell set converted H3 to S2 and back is\nnot guaranteed to reproduce the original exactly, and the discrepancy\nshould be measured with `coverage_ratio` and `jaccard`, not assumed zero.\n\n## Resolution behavior across systems\n\nBecause the three numbering systems are independent, \"matching resolution\"\nacross systems requires an explicit area-based correspondence (choosing\nthe H3 resolution, S2 level, and Geohash length whose typical cell areas\nare closest) rather than assuming numeric equivalence —\nan H3 resolution 7 cell and an S2 level 13 cell are not defined to\ncorrespond by their numbering; any correspondence used here is an\napproximate area-match stated explicitly, not a system property.\n\n## Quality metrics\n\n`coverage_ratio`, `overreach_ratio`, `underreach_ratio`, and `jaccard`\ncomputed on the re-filled destination cell set against the union polygon\nproduced in step 2 above — the standard metrics apply unchanged across\nsystems since they are defined on areas, not on index arithmetic.\n\n## Edge cases\n\n`pentagons` affects H3 (12 per resolution) and requires per-cell shape\nhandling rather than a hexagon-derived constant. `mixed-resolutions`\napplies within any single system's own hierarchy (see\n[mixed-h3-resolutions](/docs/mixed-h3-resolutions/)) and is a distinct\nconcern from cross-system resolution correspondence discussed above —\ndo not conflate mixing resolutions within H3 with matching resolutions\nacross systems.\n\n## References\n\n- H3 — Uber, [h3geo.org](https://h3geo.org/)\n- S2 Geometry — Google, [s2geometry.io](https://s2geometry.io/)\n\n## Assumptions and limitations\n\nThe matrix above reflects the generic system models registered in\n`data/cell-systems.yaml` as of this page's review date; exact resolution\nranges, compaction support, and containment guarantees should be\nre-verified against each system's current documentation before relying on\nthem for a production decision.\n\n## Illustration — hexagon vs pentagon shape\n\n> Figure: ordinary hexagon (6 sides)\n> Figure: H3 pentagon (10 boundary vertices)\n\nThe 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.\n\n## Illustration — the same area, three systems\n\n> 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."
    },
    {
      "title": "Conversion Conformance Testing",
      "slug": "conversion-conformance-testing",
      "category": "quality",
      "summary": "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.",
      "source_geometry": [],
      "destination_geometry": [],
      "status": "stable",
      "cell_systems": [
        "h3"
      ],
      "platforms": [
        "generic"
      ],
      "edge_cases": [
        "pentagons",
        "antimeridian",
        "tiny-polygons",
        "narrow-polygons",
        "holes"
      ],
      "related": [
        "h3-to-inscribed-circle",
        "h3-to-circumscribed-circle",
        "conversion-quality-metrics"
      ],
      "badges": [],
      "last_reviewed": "2026-07-22",
      "url": "https://etherdata.ai/blog/geo-interop-kb/docs/conversion-conformance-testing",
      "markdownUrl": "https://etherdata.ai/geo-interop-kb/docs/conversion-conformance-testing.md",
      "markdown": "Unit tests written against a handful of convenient cells — a mid-latitude\nhexagon, a simple square polygon — will pass for years and still hide bugs\nthat only surface on the twelve pentagons per resolution, cells that\nstraddle the antimeridian, or cells near the poles where the icosahedron\nprojection distorts most. Conformance testing in this knowledge base has two\nlayers: machine-readable fixtures that pin exact expected numeric outputs\nwith explicit tolerances, and property-based tests that check invariants\nwhich must hold across every input, not just the ones a fixture happens to\ncover.\n\n## Fixture shape\n\nA fixture is a JSON object validated against `ConformanceFixtureSchema`:\n`id`, an optional `description` and `method` name, an `input` record, a\n`parameters` record, an `expected` record, a `tolerance` record (per-field\nabsolute tolerances), and free-text `notes`. Fixtures are re-derived on\nevery test run by calling the real library function with `input` and\n`parameters` and comparing the result to `expected` within `tolerance` —\nthey are not just stored answers, they are executable regression checks\nagainst the exact functions documented elsewhere in this KB\n(`inscribedCircle`, `circumscribedCircle`, `polygonToH3`, and so on).\n\n```json\n{\n  \"id\": \"h3-inscribed-pentagon-r5\",\n  \"description\": \"inscribed circle for cell 85080003fffffff\",\n  \"method\": \"h3-to-inscribed-circle\",\n  \"input\": {\n    \"cell\": \"85080003fffffff\"\n  },\n  \"parameters\": {\n    \"edgeSamples\": 64\n  },\n  \"expected\": {\n    \"center\": [64.700000128, 10.536199075],\n    \"radiusMeters\": 6050.9753,\n    \"cellAreaM2\": 127785582.61,\n    \"isPentagon\": true\n  },\n  \"tolerance\": {\n    \"radiusMeters\": 0.05,\n    \"cellAreaM2\": 1\n  },\n  \"notes\": [\n    \"Radii are spherical (haversine) metres.\",\n    \"inscribed cell and cell circumscribed hold within SAFETY_MARGIN.\"\n  ]\n}\n```\n\nThe tolerance on `radiusMeters` here (0.05 m on a radius of roughly 6 km) is\ntight enough to catch a regression in the edge-densification sample count\nor the safety-margin constant, but loose enough to absorb floating-point\ndifferences between test runs and library versions.\n\n## Key properties\n\nProperty-based tests do not assert one expected number; they assert a\nrelationship that must hold for every cell, polygon, or cell set fed into\nthe function under test.\n\n1. **Every sampled point in an inscribed circle lies inside the source\n   cell, within tolerance.** For a densified sample of the cell boundary (a\n   great-circle-interpolated set of points along every edge, not just the\n   original vertices), no boundary sample may fall strictly inside the\n   inscribed disk — if one does, the disk extends past the true boundary and\n   the subset guarantee is broken.\n2. **Every sampled source-cell boundary point lies inside the\n   circumscribed circle, within tolerance.** Every point on the densified\n   boundary must be at or within the circumscribed radius from the cell\n   center; a violation means the circle fails to fully contain the cell it\n   claims to bound.\n3. **Fully-contained cell results do not extend outside the source\n   polygon.** For `full`-mode polyfilling, every returned cell's own\n   boundary, not just its center, must lie within the source polygon — a\n   center-contained check is not sufficient evidence for a `full`-mode\n   guarantee, and the test must verify the stronger claim the mode name\n   makes.\n4. **Compact then uncompact preserves the normalized set.**\n   `uncompact(compact(cells), resolution)` must return exactly the same set\n   of cells (as a set, not an ordered list) that `uncompact` produced from\n   the original mixed or uniform input at that resolution — compaction is a\n   lossless re-encoding of a cell set, and any input/output mismatch is a\n   correctness bug, not an approximation.\n5. **Weighted overlap fractions sum consistently.** For a cell fully\n   partitioned by a set of regions (no gaps, no overlaps in the source\n   regions), the sum of `cellCoverageFraction` across all\n   `(cell, region)` links for a given cell must equal 1 within numerical\n   tolerance — a sum below 1 indicates a missed region overlap, and a sum\n   above 1 indicates double-counted area.\n\n## Normal and pathological fixtures\n\nFixtures are organized to cover both the common case and the failure modes\nthat only appear geometrically:\n\n| | |\n|---|---|\n| Normal hexagon | A mid-latitude, non-pentagon, non-boundary-crossing cell — the baseline case every function must get right before anything else matters. |\n| Pentagon | One of the twelve pentagon cells per resolution, which break the six-neighbor and regular-hexagon-ratio assumptions several algorithms silently rely on. |\n| 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. |\n| Near-polar | A cell at high latitude where icosahedron face distortion is largest and small-angle approximations in some geometry libraries break down. |\n| Hole | A polygon with an interior ring, verifying that cells inside the hole are correctly excluded rather than treated as covered. |\n| 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. |\n| Tiny | A polygon much smaller than a single cell, testing that intersect-mode still returns the enclosing cell rather than an empty set. |\n\nEach pathological category maps back to a named edge case elsewhere in this\nKB — pentagons to [h3-pentagons](/docs/h3-pentagons/), antimeridian cells to\n[antimeridian-handling](/docs/antimeridian-handling/), and so on — so a\nfailing fixture points directly at the conceptual page explaining why the\ninput is hard, not just at a numeric mismatch.\n\n## Running the checks\n\nFixtures live as one JSON file per case under a conformance directory,\nindexed by an `index.json` manifest; the test harness reads every fixture,\nre-derives the result with the real library function named in `method`, and\nasserts the numeric fields fall within their declared tolerance while\nboolean fields (such as `isPentagon`) match exactly. Property tests are\nseparate, hand-written test suites that iterate representative cells —\ntypically an ordinary hexagon, a pentagon, a near-polar cell, and an\nantimeridian cell in the same suite — and assert the relationships in the\nKey Properties section above hold for every one of them, not just for\nwhichever fixture happens to be checked in.\n\n> **Note:** A new geometry conversion function is not conformance-tested until it has at\nleast one fixture per pathological category above, plus a property test for\nthe invariant the function is supposed to guarantee. A function with only\nnormal-case fixtures has not been tested against the inputs most likely to\nbreak it."
    },
    {
      "title": "Conversion Profiles",
      "slug": "conversion-profiles",
      "category": "concepts",
      "summary": "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.",
      "source_geometry": [],
      "destination_geometry": [],
      "status": "stable",
      "cell_systems": [
        "h3"
      ],
      "platforms": [
        "generic"
      ],
      "edge_cases": [
        "minimum-radius",
        "mixed-resolutions"
      ],
      "related": [
        "resolution-selection",
        "arbitrary-polygon-to-h3",
        "h3-to-inscribed-circle",
        "privacy-and-minimum-aggregation"
      ],
      "badges": [],
      "last_reviewed": "2026-07-22",
      "url": "https://etherdata.ai/blog/geo-interop-kb/docs/conversion-profiles",
      "markdownUrl": "https://etherdata.ai/geo-interop-kb/docs/conversion-profiles.md",
      "markdown": "A conversion profile is a named bundle of choices — containment mode,\nresolution policy, circle mode, weighting, and (where relevant) a privacy\nrule — that together answer one stated intent. Profiles exist so that\n\"convert this to H3\" is never an underspecified request: naming a profile\nforces every downstream consumer to know, without re-deriving it, what\nguarantees hold and what tradeoffs were accepted. They are defaults for an\nintent, not universal truths; every profile below states the case where it\nis the wrong choice.\n\n## The seven profiles\n\n| | |\n|---|---|\n| partition_stable | Mutually-exclusive administrative partitions — every location belongs to exactly one region. |\n| coverage_complete | Delivery/serviceable-area coverage where missing ground is worse than spilling over. |\n| measurement_weighted | Reporting and analytics that apportion values across many-to-many cell/region relationships. |\n| experiment_conservative | Geo-experiments that must minimize treatment/control contamination. |\n| proximity_full_reach | Maximizing reach around a set of cells or points, with overlap accepted and reported. |\n| platform_limit_optimized | Fitting a target set within a platform's target-count and minimum-radius limits. |\n| privacy_safe | Aggregation that avoids small-cell re-identification and sparse-audience exposure. |\n\n## Per-profile detail\n\n### partition_stable\n\nContainment mode is `center`: a cell belongs to a region if and only if the\ncell's center falls inside it. Resolution policy is a single uniform\nresolution across the whole partition. No weighting is applied. This\nguarantees a deterministic, single assignment per cell with no double\ncounting — the property most rollup and budgeting logic assumes. The\ntradeoff is boundary undercoverage: cells whose center sits just outside a\nregion are dropped from it, and a region narrower than one cell at the\nchosen resolution can receive zero cells. Recommended metrics:\n`coverage_ratio` and `underreach_ratio` per region.\n\n### coverage_complete\n\nContainment mode is `intersect`: any cell touching the source polygon with\npositive intersection area qualifies. Resolution should be fine enough that\nthe resulting overreach is acceptable for the use case. No weighting. This\nguarantees every point of the source polygon is covered by at least one\ncell — the property serviceable-area and reach use cases need. The\ntradeoff is boundary overreach and overlap with neighboring regions, since\nintersect-mode cells are not mutually exclusive. Recommended metrics:\n`coverage_ratio`, `overreach_ratio`, `jaccard`.\n\n### measurement_weighted\n\nContainment mode is `intersect`, but the output is the full\n[weighted crosswalk](/docs/h3-to-administrative-crosswalk/) table — every\n`(cell, region)` overlap retained with its intersection area and coverage\nfraction — rather than a single assignment. Resolution should match the\nmeasurement grain, and the weighted table must be kept, not collapsed to an\nargmax. Weighting can be area, population, audience, or inventory, applied\nvia the coverage fraction as an apportionment factor. This guarantees a\ncomplete many-to-many overlap record; the tradeoff is materially more\nstorage and the need to source and version whatever weight is applied.\nRecommended metrics: `coverage_ratio`, `jaccard`.\n\n### experiment_conservative\n\nContainment mode is `full` (cell fully inside the source polygon) combined\nwith the [inscribed circle](/docs/h3-to-inscribed-circle/) for any\npoint+radius execution; resolution policy favors coarser cells or explicit\nbuffering to create non-touching experimental units. No weighting. This\nguarantees executed geometry is a subset of the intended unit — no spill\ninto neighboring units, which is the property a treatment/control design\ndepends on. The tradeoff is deliberate underreach: boundary area and cell\ncorners are left unserved by construction. Recommended metrics:\n`underreach_ratio`, `uncovered_area_m2`.\n\n### proximity_full_reach\n\nCircle mode is [circumscribed](/docs/h3-to-circumscribed-circle/); resolution\npolicy is one outer circle per cell in the target set. No weighting. This\nguarantees cell subset-of circle — complete coverage of every target cell.\nThe tradeoff is that neighboring circles overlap, producing duplicate\neligibility that must be reported via `duplicate_eligibility_area_m2`, not\nhidden. Recommended metrics: `overreach_ratio`,\n`duplicate_eligibility_area_m2`.\n\n### platform_limit_optimized\n\nContainment mode is `intersect`, circle mode is circumscribed where circles\nare needed; resolution policy is to compact the cell set and coarsen\niteratively until the result is under the platform's `maxTargets` cap and\nabove its `minRadius` floor. No weighting. This guarantees the result\nrespects the named platform's target-count and minimum-radius constraints —\nthe property that determines whether a buy can even be submitted. The\ntradeoff is that coarsening enlarges the effective footprint, trading\nprecision for platform compatibility. Recommended metrics: `overreach_ratio`,\n`coverage_ratio`.\n\n### privacy_safe\n\nContainment mode is `center`; resolution policy enforces both a minimum\nphysical cell size and a minimum audience threshold, degrading resolution\nand suppressing sparse cells as needed. No weighting. Privacy rule:\nk-anonymity threshold, sparse-cell suppression, and a minimum aggregation\nwindow applied together, not any one alone. This guarantees no cell is\nreported below the configured audience or area threshold. The tradeoff is\nresolution degradation and suppression reducing granularity, sometimes\nsubstantially in sparse geographies. Recommended metrics: `coverage_ratio`,\nalongside the suppression count itself (cells dropped, not just cells\nkept).\n\n## Choosing among them\n\nThe profiles are not mutually exclusive stages of one pipeline — a single\ncampaign typically needs more than one. A privacy_safe audience definition\nmight feed a proximity_full_reach execution for delivery, measured against a\nmeasurement_weighted crosswalk for reporting, with an\nexperiment_conservative subset carved out for a lift study. Naming which\nprofile governs which stage of a pipeline is what keeps the choices\nauditable; using one resolution and one containment rule for all of them,\nbecause it happened to work for the first stage, is the failure mode this\npage exists to prevent.\n\n> **Note:** Every guarantee above holds only under the stated containment mode and\nresolution policy. A `coverage_complete` cell set has near-zero\n`underreach_ratio` by construction, but that says nothing about its\n`overreach_ratio` — high coverage and high overreach are not mutually\nexclusive, and a profile chosen for one guarantee does not automatically\ndeliver the others. See [conversion quality metrics](/docs/conversion-quality-metrics/)\nfor how to check a profile actually delivered what it promises on a\nspecific geography."
    },
    {
      "title": "Conversion Quality Metrics",
      "slug": "conversion-quality-metrics",
      "category": "quality",
      "summary": "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.",
      "source_geometry": [],
      "destination_geometry": [],
      "exactness": "exact",
      "status": "stable",
      "cell_systems": [
        "h3"
      ],
      "platforms": [
        "generic"
      ],
      "edge_cases": [
        "tiny-polygons",
        "touching-only"
      ],
      "related": [
        "requested-vs-executed-geography",
        "h3-to-circumscribed-circle"
      ],
      "badges": [
        "Exact"
      ],
      "last_reviewed": "2026-07-22",
      "url": "https://etherdata.ai/blog/geo-interop-kb/docs/conversion-quality-metrics",
      "markdownUrl": "https://etherdata.ai/geo-interop-kb/docs/conversion-quality-metrics.md",
      "markdown": "exact\n\nEvery conversion in this knowledge base can be scored against its source\ngeometry using a small, fixed set of area-based metrics. All areas below are\nspherical square meters computed on EPSG:4326 rings (turf's spherical area\nestimate); all metrics compare exactly two geometries at a time — a\n**source** (what was requested or normalized) and an **execution** (what\nwill actually run, or did run).\n\n## Base quantities\n\n| | |\n|---|---|\n| source area | area(source) — the normalized source polygon's area, m2. |\n| execution area | area(execution) — the executed geometry's area (circle, simplified polygon, cell-set outline), m2. |\n| intersection | area(source cap execution) — ground both claim, m2. |\n| union | area(source cup execution) — ground either claims, m2. |\n| uncovered_area | area(source minus execution) — asked-for ground with no execution coverage, m2. |\n| 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. |\n\nPopulation, audience, and inventory covered are derived quantities, not\nindependent metrics: they are computed by applying a per-cell weight\n(population density, audience count, inventory volume) to the intersection\narea or the coverage fraction, exactly as the\n[weighted crosswalk](/docs/h3-to-administrative-crosswalk/) does. They\ninherit the same denominator caveats as `coverage_ratio` below and should\nalways be reported alongside the ratio, not instead of it.\n\n## The four ratios\n\nEach ratio uses `source area` as the denominator except Jaccard, which uses\nthe union. Read the denominator before comparing two ratios across\ndifferent geometries.\n\n$$\n\\text{coverage\\_ratio} = \\frac{\\text{area}(\\text{source} \\cap \\text{execution})}{\\text{area}(\\text{source})}\n$$\n\n$$\n\\text{overreach\\_ratio} = \\frac{\\text{area}(\\text{execution} - \\text{source})}{\\text{area}(\\text{source})}\n$$\n\n$$\n\\text{underreach\\_ratio} = \\frac{\\text{area}(\\text{source} - \\text{execution})}{\\text{area}(\\text{source})}\n$$\n\n$$\n\\text{jaccard} = \\frac{\\text{area}(\\text{source} \\cap \\text{execution})}{\\text{area}(\\text{source} \\cup \\text{execution})}\n$$\n\n> **Note:** `coverage_ratio` and `overreach_ratio` share a numerator family but are not\ncomplementary — they do not sum to 1, and neither bounds the other.\nCircumscribing every cell in a region produces `coverage_ratio` at or above\n0.999 (the source is fully contained in the union of circles, by\nconstruction) while `overreach_ratio` is strictly positive and can exceed 1\nif the circles are large relative to the source polygon — meaning the\nexecuted geometry is larger than the entire source, not merely imperfectly\naligned with it. Never report `coverage_ratio` alone as a proxy for\ntargeting precision; always pair it with `overreach_ratio` or `jaccard`.\n\n`underreach_ratio` is the complement structure to watch instead: for a\nsingle-source, single-execution comparison,\n`coverage_ratio + underreach_ratio = 1` always holds, because\n`area(source cap execution) + area(source - execution) = area(source)` by\nset-algebra identity regardless of what the execution geometry looks like.\n`overreach_ratio` has no such fixed relationship to the other two because\nits numerator is measured against execution, not source.\n\n## Boundary displacement, counts, and coverage denominators\n\nBoundary displacement is a distance metric, not an area metric: the\nmaximum or mean perpendicular distance between the source boundary and the\nnearest point on the execution boundary, in meters. It answers \"how far did\nthe edge move,\" which `overreach_ratio` and `underreach_ratio` cannot answer\non their own — a geometry can have small `overreach_ratio` and still have a\nboundary that moved considerably if the source polygon is large relative to\nthe displacement.\n\nCounts (cells, targets, regions) are reported alongside area metrics but are\nnot substitutes for them: cell count says nothing about coverage without\nknowing the resolution, and a small cell count at a coarse resolution can\ncover more area than a large cell count at a fine resolution.\n\"Population/audience/inventory covered\" figures must always be reported\nwith the coverage_ratio and underreach_ratio that produced them, since a\nweighted total with no denominator context cannot be checked against the\nsource ask.\n\n## Algorithm\n\n```ts\n\n// Coverage/overreach/underreach/jaccard for one cell approximated by its\n// circumscribed circle.\nconst cell = \"872830829ffffff\";\nconst sourcePoly = cellToPolygon(cell);\nconst circle = circumscribedCircle(cell);\nconst executionPoly = turf.circle(\n  [circle.center[1], circle.center[0]],\n  circle.radiusMeters / 1000,\n  { units: \"kilometers\", steps: 128 },\n);\n\nconst m = coverageMetrics(sourcePoly, executionPoly);\n// m.coverageRatio ~ 1 (circumscribed circle fully contains the cell)\n// m.overreachRatio > 0 (the disk covers ground outside the hexagon)\n// m.jaccardSimilarity < 1 (disk area exceeds cell area)\n\n// Duplicate eligibility across two adjacent cells' circumscribed circles.\nconst neighborCircle = circumscribedCircle(\"872830828ffffff\");\nconst neighborPoly = turf.circle(\n  [neighborCircle.center[1], neighborCircle.center[0]],\n  neighborCircle.radiusMeters / 1000,\n  { units: \"kilometers\", steps: 128 },\n);\nconst dupArea = duplicateEligibilityAreaM2([executionPoly, neighborPoly]);\n// dupArea > 0: ground eligible under both circles.\n```\n\nThe same conversion with the Python bindings (`h3-py` v4):\n\n```python\n\nfrom shapely.geometry import Polygon\n\ndef cell_to_polygon(cell: str) -> Polygon:\n    boundary = h3.cell_to_boundary(cell)\n    return Polygon([(lng, lat) for lat, lng in boundary])\n\ndef coverage_metrics(source: Polygon, execution: Polygon) -> dict:\n    intersection = source.intersection(execution).area\n    union = source.union(execution).area\n    return {\n        \"coverage_ratio\": intersection / source.area,\n        \"overreach_ratio\": execution.difference(source).area / source.area,\n        \"underreach_ratio\": source.difference(execution).area / source.area,\n        \"jaccard\": intersection / union,\n    }\n\ncell = \"872830829ffffff\"\nsource_poly = cell_to_polygon(cell)\n\n# Executed geometry approximated by a circumscribed circle around the cell\n# center — build it the same way the TS lib's circumscribedCircle does\n# (great-circle radius, densified boundary), not with a planar buffer.\nexecution_poly = circumscribed_circle_polygon(cell)\n\nm = coverage_metrics(source_poly, execution_poly)\n# m[\"coverage_ratio\"] ~ 1 (circle fully contains the hexagon)\n# m[\"overreach_ratio\"] > 0 (the disk covers ground outside the hexagon)\n# m[\"jaccard\"] < 1 (disk area exceeds cell area)\n\n# Cell-count-free area: h3.cell_area never requires counting cells to size\n# a region, unlike a count-times-nominal-area estimate.\nexact_cell_area_m2 = h3.cell_area(cell, unit=\"m^2\")\n```\n\nThe tested reference implementation in this knowledge base is the\nTypeScript in `lib/`; it computes every area above as spherical\n(haversine-consistent) m², whereas `shapely`'s `.area` on raw lat/lng\ncoordinates is planar and only adequate for a rough illustration at this\nscale.\n\n> Figure: full: coverage 49.4%, overreach 0%\n> Figure: intersect: coverage 100%, overreach 68%\n\n## Reading the outputs together\n\nA conversion report should never publish a single ratio in isolation.\n`coverage_ratio` alone cannot distinguish a tightly-fit execution from a\ngrossly oversized one that happens to fully contain the source; pairing it\nwith `overreach_ratio` (or `jaccard`, which penalizes both under- and\nover-coverage in one number) closes that gap.\n`duplicate_eligibility_area_m2` is the only metric here that requires more\nthan two geometries — it is defined over a set of executed features, and is\nthe correct diagnostic for \"how much ground is double-counted,\" which\nneither `overreach_ratio` nor `jaccard` computed pairwise can reveal, since\noverlaps between two non-source features never appear in a source-vs-single-execution\ncomparison.\n\n## Edge cases\n\nTiny polygons ([tiny-polygons](/docs/geometry-catalogue/)) produce unstable\nratios when the source area approaches the numerical noise floor of the\narea calculation — a source polygon a few square meters in extent can show\n`overreach_ratio` in the hundreds or thousands purely because the\ndenominator is small, not because the execution is unusually bad; treat\nextreme ratios on tiny sources as a signal to inspect absolute areas, not as\na literal severity score. Touching-only intersections\n([touching-only](/docs/geometry-catalogue/)) — where a boundary-adjacent\ncell shares only an edge or point with the source, contributing\nnear-zero intersection area — should be filtered by an intersection-area\nepsilon before computing `coverage_ratio`, or a geometrically-touching but\npractically-irrelevant cell will be counted as \"covering\" the source.\n\n## Illustration — duplicate eligibility\n\n> Figure (duplicate-eligibility): Two adjacent cells executed as circumscribed circles: the pink lens is ground eligible under both targets — the duplicate-eligibility area."
    },
    {
      "title": "Coordinate And CRS Failures",
      "slug": "coordinate-and-crs-failures",
      "category": "systems",
      "summary": "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.",
      "source_geometry": [
        "address",
        "device_ping",
        "census_geo",
        "admin_county",
        "postal_code"
      ],
      "destination_geometry": [
        "bounding_box"
      ],
      "exactness": "exact",
      "status": "stable",
      "cell_systems": [
        "h3"
      ],
      "platforms": [
        "generic"
      ],
      "edge_cases": [
        "axis-order-reversal",
        "rounded-coordinates",
        "duplicated-region-ids",
        "stale-boundaries"
      ],
      "related": [
        "geometry-normalization",
        "point-to-h3"
      ],
      "badges": [
        "Exact"
      ],
      "last_reviewed": "2026-07-22",
      "url": "https://etherdata.ai/blog/geo-interop-kb/docs/coordinate-and-crs-failures",
      "markdownUrl": "https://etherdata.ai/geo-interop-kb/docs/coordinate-and-crs-failures.md",
      "markdown": "exact\n\n## Purpose\n\nEvery conversion in this knowledge base assumes its input is valid EPSG:4326\ngeometry with a correct, current boundary vintage. That assumption fails\nroutinely, in specific and detectable ways. This page is the checklist run\nbefore normalization: nine failure modes, each with a detection test cheap\nenough to run on every incoming record and a mitigation that does not\nsilently guess at the correct value.\n\n## Wrong or missing CRS\n\nA geometry supplied without an explicit CRS is frequently assumed to be\nEPSG:4326 by convention, but shapefiles and some GIS exports default to a\nstate-plane or UTM projected CRS instead. **Detection**: coordinate\nmagnitudes outside [−180, 180] for longitude or [−90, 90] for latitude\nimmediately rule out unprojected geodetic degrees — a value of 487213.6 is a\nprojected easting, not a longitude. **Mitigation**: require an explicit CRS\ntag on ingest and reject records without one rather than defaulting to\nEPSG:4326; if a CRS is declared but suspect, range-check before trusting it,\nand reproject explicitly rather than relying on downstream code to\n\"figure it out.\"\n\n## Axis-order reversal (lat/lng swap)\n\nGeoJSON specifies `[longitude, latitude]`; many non-GeoJSON sources (some\nCSV exports, legacy APIs) use `[latitude, longitude]`. A swapped point in\nthe continental US (actual `[-97.5, 35.2]` stored as `[35.2, -97.5]`)\nproduces a coordinate that is still in valid range but lands in the wrong\nhemisphere. **Detection**: a coordinate out of range in its declared order\nbut valid when swapped is diagnostic; more generally, cross-check a sample\nagainst a known reference (does the `[lat, lng]` reading place the\nrecord's stated city inside the right country's bounding box?).\n**Mitigation**: range-check both interpretations and swap when only one is\nvalid; when both orders produce a plausible point (common near the equator\nand prime meridian, where the ranges overlap), reject rather than guess —\na silent swap in the ambiguous zone is worse than a flagged gap.\n\n## Stale boundaries\n\nAdministrative, postal, and DMA boundaries are revised periodically (annual\nDMA realignments, postal code splits, county adjustments after annexation).\nUsing an old vintage assigns points and cells to a boundary that no longer\nmatches the authority's current definition. **Detection**: compare the\nboundary dataset's effective date against the activity period being\nanalyzed; any boundary older than the most recent known revision is\nsuspect. **Mitigation**: version every boundary explicitly with\n`validFrom`/`validTo`, and record which vintage was used in the\n`ConversionRecord` for every crosswalk — never treat \"the boundary file we\nhave\" as \"the boundary in effect on the date in question.\"\n\n## Duplicated region IDs\n\nA data-quality error where the same administrative or postal ID is\nattached to two or more disjoint polygon features — most often from a bad\njoin or an un-deduplicated append of two vintages. This breaks every\npartition assumption downstream: a max-overlap or weighted crosswalk keyed\non that ID silently sums or selects across features that are not the same\nregion. **Detection**: group features by ID and flag any ID mapping to more\nthan one geometry that is not a legitimate multipolygon. **Mitigation**:\ndedupe or union by ID at ingest, and fail loudly — reject the batch —\nrather than silently picking one of the duplicates.\n\n## Rounded or truncated coordinates\n\nBidstream and some third-party feeds round coordinates to 2–3 decimal\nplaces to reduce payload size or as a privacy measure, snapping a point to\na grid far coarser than it implies (2 decimals is roughly 1.1 km at the\nequator; 3 decimals is roughly 111 m). **Detection**: check the number of\nsignificant decimal digits present; fewer than 4 (roughly 11 m or coarser)\nis suspect for any point-level conversion. **Mitigation**: cap the\neffective H3 resolution to one consistent with the coordinate's actual\nprecision — assigning a 2-decimal point to an R9 cell (roughly 0.1 km²)\nimplies false precision; treat it as \"somewhere within this larger cell,\"\nnot an exact location.\n\n## Incomplete geometry\n\nA polygon record with an open ring, a missing final closing vertex, or a\ntruncated coordinate array (a common symptom of a failed export or a\nsize-limited API response). **Detection**: check ring closure (first vertex\nequals last vertex) and a minimum vertex count (a ring needs at least 4\npositions: 3 distinct vertices plus the closing repeat). **Mitigation**:\nclose open rings only when the gap is a single missing repeat of the first\nvertex; reject rings with fewer than the minimum or with a gap large enough\nthat closing it would materially change the shape, rather than\nauto-closing across an arbitrary gap.\n\n## Geocoding uncertainty\n\nAn address geocoded to a point carries an accuracy tier (rooftop, parcel\ncentroid, street interpolation, city centroid) that is frequently dropped\nby the time the point reaches a conversion pipeline, leaving a point that\nlooks rooftop-precise but is actually a city centroid. **Detection**:\nrequire the geocoder's accuracy tier to travel with the point as metadata;\nits absence on a geocoded, non-GPS point is itself the signal.\n**Mitigation**: cap the effective resolution used for downstream cell\nassignment to match the disclosed accuracy tier, as with rounded\ncoordinates above.\n\n## Zero-island coordinates\n\nA point at exactly `(0, 0)` — the equator/prime-meridian intersection, in\nopen ocean off West Africa — is the default, unset value for many numeric\ncoordinate fields (an uninitialized float pair, a failed geocode with nulls\ncoerced to zero, a parsing error). It is a valid coordinate but is\ndisproportionately never a real observation. **Detection**: flag any\nrecord at `(0, 0)` to within floating-point tolerance for review rather\nthan accepting it as a legitimate ping. **Mitigation**: treat as a\nmissing-value sentinel by default; accept as real only with explicit\ncorroborating evidence.\n\n## Assumptions and limitations\n\nAll range checks in this page use the standard geodetic bounds: longitude\nin [−180, 180], latitude in [−90, 90]. These checks catch out-of-range and\nswap-detectable errors; they do not catch a coordinate that is in-range,\ncorrectly ordered, and still simply wrong (a correct-looking point placed at\nthe wrong address) — that class of error requires corroboration against an\nindependent source, which is outside the scope of coordinate-level\nvalidation."
    },
    {
      "title": "Geographic Interoperability Model",
      "slug": "geographic-interoperability-model",
      "category": "concepts",
      "summary": "The six geographies that a single campaign passes through, why they must stay distinct, and why provenance has to survive every conversion.",
      "source_geometry": [],
      "destination_geometry": [],
      "status": "stable",
      "cell_systems": [
        "h3"
      ],
      "platforms": [
        "generic"
      ],
      "edge_cases": [
        "stale-boundaries"
      ],
      "related": [
        "requested-vs-executed-geography",
        "geometry-normalization",
        "conversion-quality-metrics"
      ],
      "badges": [],
      "last_reviewed": "2026-07-22",
      "url": "https://etherdata.ai/blog/geo-interop-kb/docs/geographic-interoperability-model",
      "markdownUrl": "https://etherdata.ai/geo-interop-kb/docs/geographic-interoperability-model.md",
      "markdown": "A geographic target looks like one object — \"the New York market\" — but it is\nhandled as a chain of **distinct** objects, each a lossy or lossless transform of\nthe last. Conflating them is the root cause of most \"the numbers don't match\"\ndisputes between buying, execution, and measurement.\n\n## The canonical pipeline\n\n```mermaid\nflowchart TB\n  R[\"Requested geography<br/>(what the buyer asked for)\"] --> S[\"Source geography<br/>(what was supplied)\"]\n  S --> N[\"Normalized geometry<br/>(EPSG:4326, repaired)\"]\n  N --> H[\"Canonical H3<br/>(resolution + containment rule)\"]\n  H --> X[\"Executed geography<br/>(circles / polygons / IDs)\"]\n  X --> P[\"Reported geography\"]\n  P --> A[\"Attributed geography\"]\n```\n\nEvery arrow is a conversion documented elsewhere in this knowledge base, and\nevery arrow can change the geography. The model exists so that each object has a\nname, a schema, and a provenance record — and so a claim like \"we targeted the\npolygon\" can be checked against what was actually executed.\n\n## The six geographies\n\n| | |\n|---|---|\n| 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. |\n| Source | The geometry actually supplied to represent the ask: a shapefile, a GeoJSON polygon, a point list. May already differ from the request. |\n| Normalized | Source geometry validated and reprojected to EPSG:4326 GeoJSON: closed rings, correct winding, antimeridian split, holes respected. |\n| Canonical H3 | A set of H3 cells at a stated resolution under a stated containment rule. The interchange form all downstream conversions start from. |\n| Executed | What a platform can actually run: point+radius circles, simplified polygons, or native geo IDs — an approximation of the H3 set. |\n| Reported / attributed | The geography used for delivery reporting and outcome attribution — frequently coarser than what was executed. |\n\n> **Note:** An H3 cell executed as a circumscribed circle covers ground the cell does not.\nA DMA polyfilled to H3 and then mapped back to postal codes is not the same set\nof households you started with. If a report says \"postal code\" but execution ran\non circles, the attribution geography and the executed geography disagree — and\nthat gap is measurable, not rhetorical.\n\n## Why provenance must survive conversion\n\nEach hop should append to a `ConversionRecord`, never overwrite it. The minimum\nthat must be recoverable at the end of the chain:\n\n- the **source** CRS, vendor, and boundary vintage;\n- the **normalization** actions taken (what was repaired);\n- the H3 **resolution** and **containment mode**;\n- the **approximation mode** used for execution (inner/outer/equal-area circle,\n  polygon simplification tolerance, crosswalk vintage);\n- which **exclusions or targets were dropped** because a platform could not\n  express them.\n\nWithout this, you cannot answer the questions this knowledge base is organized\naround: *what was requested, what was executed, and why do they differ?*\n\n## A worked gap\n\nSuppose the request is a county (a partition unit), executed on a DSP that only\naccepts point+radius circles.\n\n$$\n\\text{county polygon}\n\\xrightarrow{\\text{polyfill, R7, intersect}}\n\\{h_1 \\dots h_n\\}\n\\xrightarrow{\\text{circumscribed}}\n\\{(c_i, r_i)\\}\n$$\n\nThe intersect polyfill already overreaches at the county boundary; the\ncircumscribed circles overreach again and overlap each other. The executed\nfootprint is strictly larger than the requested county, and some ground is\neligible under two circles at once. None of that is wrong — but it must be\n**reported**, via the [quality metrics](/docs/conversion-quality-metrics/), not\nhidden behind the phrase \"we targeted the county.\"\n\nThis page has no `ts` algorithm block of its own — the pipeline it narrates\nis coded page by page elsewhere in this knowledge base. As a short\nillustration, the same Requested → Canonical H3 → Executed steps with the\nPython bindings (`h3-py` v4):\n\n```python\n\n# Requested -> Source -> Normalized: a county ring, already reprojected to\n# EPSG:4326 as (lat, lng) pairs.\ncounty_ring = [\n    (40.70, -74.02), (40.70, -73.98),\n    (40.74, -73.98), (40.74, -74.02),\n    (40.70, -74.02),\n]\ncounty_shape = h3.LatLngPoly(county_ring)\n\n# Normalized -> Canonical H3, at resolution 7.\n# NOTE: h3-py's polygon_to_cells uses CENTER containment, like h3-js — the\n# \"intersect\" rule this worked example uses is not a one-liner; it means\n# classifying each candidate cell yourself (shapely intersection area\n# against the source ring), exactly as the TS lib's polygonToH3 does.\ncanonical_cells = h3.polygon_to_cells(county_shape, res=7)\n\n# Canonical H3 -> Executed: dissolve the cell set back into a polygon\n# footprint (here, a stand-in for the circumscribed-circle executed\n# geometry the worked example above actually uses).\nexecuted_shape = h3.cells_to_h3shape(canonical_cells, tight=True)\n```\n\nThe tested reference implementation in this knowledge base is the\nTypeScript in `lib/`, which implements the `intersect` and `full`\ncontainment rules explicitly rather than relying on `h3-py`'s\ncenter-containment default.\n\n> Figure (polygon-intersect): A requested square vs its executed H3 intersect fill: 100% coverage but 68% overreach — requested is not executed.\n\n> **Note:** Rule of thumb: never say \"converted to H3\" or \"same geography\" without also\nstating the resolution, the containment rule, the approximation mode, and the\nunits. Every page here that describes a conversion is required to state all four."
    },
    {
      "title": "Geohash Overview",
      "slug": "geohash-overview",
      "category": "systems",
      "summary": "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.",
      "source_geometry": [
        "h3_cell_set"
      ],
      "destination_geometry": [
        "h3_cell_set"
      ],
      "exactness": "approximate",
      "status": "stable",
      "cell_systems": [
        "geohash"
      ],
      "platforms": [
        "generic"
      ],
      "edge_cases": [
        "antimeridian",
        "mixed-resolutions"
      ],
      "related": [
        "cell-system-comparison",
        "h3-overview"
      ],
      "badges": [
        "Approximate"
      ],
      "last_reviewed": "2026-07-22",
      "url": "https://etherdata.ai/blog/geo-interop-kb/docs/geohash-overview",
      "markdownUrl": "https://etherdata.ai/geo-interop-kb/docs/geohash-overview.md",
      "markdown": "approximate\n\n## What Geohash is\n\nGeohash is a discrete global grid system that encodes a lat/lng rectangle\nas a base-32 string. It works directly in unprojected latitude/longitude\ndegrees — no icosahedron, cube, or dodecahedron projection is involved the\nway there is for [H3](/docs/h3-overview/) or\n[S2](/docs/s2-overview/) — which is both its simplicity and the\nsource of its most severe geometric limitation (non-equal-area cells that\nshrink sharply toward the poles, described below). Geohash is documented\nhere on the same generic terms as the other systems; see\n[cell-system-comparison](/docs/cell-system-comparison/) for the full\nmatrix.\n\n## Construction: recursive bisection, base-32 encoding\n\nA geohash is built by recursively bisecting a starting bounding box\n(the whole lat/lng extent) in half, alternating which axis is bisected —\nlongitude first, then latitude, then longitude again — and appending a\nbit for which half the target point falls in (0 for the lower half, 1 for\nthe upper half). Every 5 bits accumulated this way are encoded as one\nbase-32 character. Because each character encodes 5 bits and the axis\nalternates every bit (not every character), each additional character\nsubdivides the current rectangle into 32 pieces arranged as an 8-wide by\n4-tall grid where longitude got the extra bit, or 4-wide by 8-tall where\nlatitude did — the two patterns alternate by string position. The net\neffect: each character subdivides the parent rectangle into 32 children,\nnone of which is a hexagon, spherical quadrilateral, or pentagon — simply\na smaller lat/lng rectangle in degrees.\n\n## Hierarchy: exact prefix containment\n\nGeohash's hierarchy relationship is a string-prefix relationship, and it\nis **exact**: every point inside a geohash of length N falls inside every\nshorter geohash that is a prefix of it, with no exception, because the\nsubdivision is a literal recursive bisection of the bounding rectangle —\nno approximation or index-arithmetic step analogous to H3's aperture-7\nlogic. Geohash's containment guarantee is as strong as S2's exact quad\ntiling, though the two arrive at exact containment by different\nconstructions (recursive bisection vs. an exact quad tree of spherical\nquadrilaterals), and Geohash's hierarchy factor is 32 children per parent\nrather than 4 (S2) or approximately 7 (H3).\n\n## Lengths and non-equal-area cells\n\nGeohash strings in common use range from 1 to 12 characters. Because the\nsubdivision is performed directly in latitude/longitude degrees rather\nthan on a projected or equal-area surface, a geohash rectangle's true\nground area at a fixed string length is **not** constant: a degree of\nlongitude covers less ground distance as latitude increases toward the\npoles (proportional to the cosine of latitude), so geohash cells of the\nsame length shrink toward the poles and are widest at the equator — a more\nsevere, more geometrically obvious area variance than H3's (roughly 2x,\nicosahedron projection) or S2's (cube projection). A geohash near a pole\ncan be a small fraction of the ground area of an equatorial geohash of the\nsame length, with no correction applied by the encoding itself.\n\n> **Note:** Because bisection is purely coordinate-based, two points that are\ngeographically adjacent but fall on opposite sides of a bisection\nboundary — most sharply at the equator, the prime meridian, or the\nantimeridian — can produce geohash strings that share no meaningful\nprefix at all, even though the points are close together. A naive\nproximity search using string-prefix similarity will silently miss\nnearby points across such a boundary; see\n[antimeridian-handling](/docs/antimeridian-handling/) for the general\nedge/discontinuity problem this is one instance of.\n\n## Core operations\n\n| | |\n|---|---|\n| 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. |\n| 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. |\n| Centroid extraction | The rectangle's midpoint is the conventional decoded 'center' of a geohash, analogous to `cellToLatLng`. |\n| 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. |\n| 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. |\n\n## Use cases: prefix bucketing and key-range scans\n\nGeohash's practical advantage is operational simplicity: a geohash string\nsorts and range-scans naturally in any ordinary string-keyed index (a\ndatabase B-tree, a key-value store, a URL path segment), and truncating a\nstring to a shorter prefix is itself the exact coarsening operation — no\nseparate \"get parent cell\" call is needed. This makes Geohash reasonable\nfor simple proximity bucketing or key-range scans where polar area\ndistortion and the lack of native polygon fill are acceptable trade-offs,\nbut a poor choice wherever precise polygon coverage or robust\nnear-boundary proximity matching is required — those are better served by\n[H3](/docs/h3-overview/) or [S2](/docs/s2-overview/).\n\n## What must not be assumed\n\nDo not assume Geohash cells are equal-area — the polar shrinkage is\nsevere and unlike either H3's or S2's projection-driven variance. Do not\nassume Geohash supports native polygon fill or compaction — both must be\nhand-built from the prefix/rectangle primitives. Do assume prefix\ncontainment is exact, and do assume string proximity is *not* a reliable\nproxy for spatial proximity near bisection boundaries.\n\n## References\n\n- Geohash — [Wikipedia: Geohash](https://en.wikipedia.org/wiki/Geohash)\n  (last verified 2026-07-22)\n\n## Assumptions and limitations\n\nThis page describes Geohash's generic capability model as registered in\n`data/cell-systems.yaml`. Specific base-32 alphabet choice, exact\nbit-per-character packing, and any platform-specific length convention\nshould be verified against the geohash implementation in use before being\nrelied on for a production calculation.\n\n## Illustration — a real geohash cell\n\n> 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."
    },
    {
      "title": "Geometry Catalogue",
      "slug": "geometry-catalogue",
      "category": "geometries",
      "summary": "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.",
      "source_geometry": [],
      "destination_geometry": [],
      "status": "stable",
      "cell_systems": [
        "h3"
      ],
      "platforms": [
        "generic"
      ],
      "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"
      ],
      "badges": [],
      "last_reviewed": "2026-07-22",
      "url": "https://etherdata.ai/blog/geo-interop-kb/docs/geometry-catalogue",
      "markdownUrl": "https://etherdata.ai/geo-interop-kb/docs/geometry-catalogue.md",
      "markdown": "This page indexes every geometry type this knowledge base converts to or\nfrom H3, grouped by category. Each entry links to the dedicated conversion\npages covering it in full; this page's job is to say, per group, what the\ngeometry fundamentally is, what metadata it needs before conversion, and\nwhat goes wrong most often.\n\n> **Note:** A FIPS code, a DMA id, a postal code — none of these is a geometry. Each is\na reference into a versioned boundary set, and the boundary it resolves to\ndepends on which vintage of that set is in force. `\"36061\"` is not a shape;\n`\"36061\" + TIGER 2024 vintage` is. Treating an identifier as if it carries\nits own geometry is the most common source of silent misassignment in this\ncatalogue, because the identifier string never changes even when the\nboundary it refers to does — see\n[stale boundaries](/docs/requested-vs-executed-geography/).\n\n## Administrative and statistical boundaries\n\nCountries, states/provinces, counties, census geographies, DMAs, and postal\nareas. These are consumed as **partitions**: downstream reporting,\nbudgeting, and compliance logic typically assumes every location belongs to\nexactly one region. Required metadata: boundary source, vintage, CRS, and a\nnamespace for the id (FIPS, GEOID, ISO code, DMA id, ZIP/ZCTA). Common\nrisks: boundary vintage drift (DMA and postal boundaries are redrawn\nperiodically), leading zeros dropped from FIPS/GEOID strings by an integer\ncast, and postal codes specifically — a ZIP code is a USPS delivery-route\nabstraction with no authoritative polygon; any \"ZIP polygon\" in circulation\nis a third party's ZCTA approximation and must be labeled as such. See\n[administrative polygon to H3](/docs/administrative-polygon-to-h3/).\n\n## Arbitrary polygons\n\nTrade areas, geofences, and parcels: operator-drawn or model-generated\nshapes with no external authority governing their boundary. Required\nmetadata is the generation method (drive-time model, gravity-model\ncatchment, manual digitization), a vintage timestamp, and CRS. Common\nrisks: self-intersection (bowtie rings from manual digitization or model\nartifacts), narrow slivers and tiny polygons that can legitimately yield\nzero center-contained cells, and holes that must be respected rather than\nfilled through. See [arbitrary polygon to H3](/docs/arbitrary-polygon-to-h3/).\n\n## Points\n\nPOI/store points, addresses, and device pings: a single coordinate that\nreferences a place rather than describing an extent. Required metadata is\nCRS, source, and a timestamp; device pings additionally need an accuracy\nradius and consent state, and addresses need the geocoder used and its\nmatch confidence. Common risks: geocoding uncertainty (rooftop vs. centroid\nplacement), axis-order reversal, and — for device pings — IP-derived\nlocations that are coarse and centroid-biased and must be labeled with the\ncorrect matching semantic rather than treated as physical presence. See\n[point to H3](/docs/point-to-h3/).\n\n## Point + radius\n\nA center coordinate with a geodesic radius — the native execution unit for\nmany DSPs and the inverse of an H3 circle approximation. Required metadata\nis the radius units and whether the radius is geodesic or planar, since a\nplanar radius diverges from a geodesic one as it grows or as latitude\nincreases. Common risks: platform minimum-radius floors that reject small\nradii outright, and radius increments that round a requested radius up or\ndown, silently changing coverage. See [point-radius to H3](/docs/point-radius-to-h3/).\n\n## Lines and trajectories\n\nRoad/transit lines and device trajectories: an unordered polyline versus an\nordered, timestamped sequence of positions. Lines need CRS and direction;\ntrajectories additionally need timestamps, sampling rate, and consent\nstate, since an ordered high-resolution trajectory can be re-identifying\neven when individual points are not. Common risks: GPS noise causing\nboundary oscillation (a path skimming a cell edge flips back and forth\nbetween cells), and, for trajectories, exposure risk distinct from a single\npoint's. See [line and corridor to H3](/docs/line-and-corridor-to-h3/).\n\n## Multipoints\n\nBid requests, visits, conversions, sensor events: a collection of\nindependent point observations rather than one coherent shape. Required\nmetadata is CRS, per-observation timestamp, and a deduplication key. The\ncentral risk is duplicate observations — the same impression or visit\ncounted more than once inflates audience or volume per cell — compounded by\nsparse-audience suppression requirements once the set is aggregated to\ncells.\n\n## Rasters\n\nGridded fields: population, elevation, weather, pollution, or\nsignal-strength surfaces. Required metadata is CRS, native pixel\nresolution, the declared no-data sentinel, and band semantics. Common\nrisks: unmasked no-data pixels corrupting an aggregate, a resolution\nmismatch between pixel size and target cell size producing false precision,\nand coastal or other mixed pixels straddling two categories without a clean\nper-cell label. See [raster to H3](/docs/raster-to-h3/).\n\n## Bounding boxes\n\nA `[west, south, east, north]` envelope — a map viewport or query bound,\nnot a shape describing any real-world extent. Required metadata is CRS and\nwhether the box is a screen/viewport rectangle or a geographic envelope,\nsince the two are easy to conflate. Common risks: a box crossing the\nantimeridian where west is numerically greater than east (a naive\nbox-to-polygon conversion produces an inverted or world-spanning result),\nand rotated boxes that are not axis-aligned. See\n[bounding box to H3](/docs/bounding-box-to-h3/).\n\n## Platform identifiers\n\nOpaque or standardized identifiers — a postal id, a FIPS code, an ISO code,\na DMA id, a publisher's own market label. This is the category the warning\nat the top of this page is about most directly: a platform identifier is\nnever a geometry in its own right. Required metadata is the id namespace,\nthe boundary vintage it was minted against, and the crosswalk source used to\nresolve it. Common risks: namespace ambiguity (two vendors' \"market 12\" mean\ndifferent things), vintage mismatches between assignment and resolution\ntime, and unmatched ids that silently drop out of a join instead of raising\nan error. See [H3 to platform-native geography](/docs/h3-to-platform-native-geography/).\n\n## Cell sets\n\nA set of H3 indices, possibly mixed-resolution and possibly compacted — the\ncanonical interchange form this knowledge base converts everything else\ninto and out of. Required metadata is the resolution (or confirmation that\nthe set is mixed-resolution), whether it has been compacted, and the\ncontainment mode used to produce it. Common risks: parent-and-child cells\nin the same set double-counting shared area, and combining sets at\ndifferent resolutions without normalizing first. See\n[H3 compaction and uncompaction](/docs/h3-compaction-and-uncompaction/) and\n[mixed H3 resolutions](/docs/mixed-h3-resolutions/).\n\n## Using this catalogue\n\nEvery conversion page here names its source and destination geometry types\nusing the ids implicit in the groups above. When a new geometry type does\nnot obviously fit one of these nine groups, ask which group's\nrequired-metadata list it actually satisfies — not which group its name\nresembles. A \"trade area\" delivered as a fixed-radius circle around a store\npoint is a point+radius geometry, not an arbitrary polygon, regardless of\nwhat the source system calls it, and converting it as a polygon would\nsilently drop the radius-units question the point+radius group forces."
    },
    {
      "title": "Geometry Normalization",
      "slug": "geometry-normalization",
      "category": "concepts",
      "summary": "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.",
      "source_geometry": [],
      "destination_geometry": [],
      "exactness": "exact",
      "status": "stable",
      "cell_systems": [
        "h3"
      ],
      "platforms": [
        "generic"
      ],
      "edge_cases": [
        "self-intersections",
        "holes",
        "multipart-geometries",
        "axis-order-reversal"
      ],
      "related": [
        "coordinate-and-crs-failures",
        "antimeridian-handling"
      ],
      "badges": [
        "Exact"
      ],
      "last_reviewed": "2026-07-22",
      "url": "https://etherdata.ai/blog/geo-interop-kb/docs/geometry-normalization",
      "markdownUrl": "https://etherdata.ai/geo-interop-kb/docs/geometry-normalization.md",
      "markdown": "exact\n\nPolyfilling, circle construction, and crosswalking all assume their input is\na valid GeoJSON polygon in EPSG:4326 with closed, correctly wound rings and no\nself-intersections. None of those assumptions hold for raw vendor data by\ndefault. Normalization is the step that either makes them hold or reports,\nby code, exactly why it cannot — it is deliberately conservative: it repairs\nwhat is safe to repair and flags the rest rather than guessing.\n\n## What normalization covers\n\n**CRS detection and reprojection.** Vendor shapefiles frequently arrive in a\nprojected CRS (state plane, Albers equal-area, UTM zones) rather than\ngeographic coordinates. Every polygon must be reprojected to EPSG:4326\n(WGS84 longitude/latitude in decimal degrees) before any H3 operation, since\nh3-js and the polyfill/circle libraries in this KB assume geographic\ncoordinates on a sphere. Reprojection error for CONUS-scale admin polygons\nis typically sub-meter, but should be verified rather than assumed for\nAlaska, Hawaii, and territories where projection choice matters more.\n\n**Axis-order validation.** GeoJSON specifies `[longitude, latitude]`; many\nGIS tools, and essentially all human-readable lat/lng pairs, use the reverse\norder. A coordinate out of range in one order but valid in the other (for\nexample `(74.006, -40.7128)`: 74.006 is out of the minus-90-to-90 latitude\nrange, but valid as `(lng, lat)` reversed) is flagged as a suspected axis\nswap rather than silently accepted, because guessing wrong places the\ngeometry in a different hemisphere.\n\n**Ring closure.** A linear ring's first and last coordinate must be\nidentical; open rings are closed by appending the first vertex.\n\n**Winding order.** RFC 7946 requires the exterior ring to wind\ncounter-clockwise and interior (hole) rings to wind clockwise, both as seen\nfrom above the plane (right-hand rule on the sphere). Reversed winding does\nnot always break area calculations, but it breaks assumptions some\npolygon-clipping and point-in-polygon implementations make about which side\nis \"inside,\" so it is corrected unconditionally.\n\n**Self-intersection.** Bowtie or otherwise self-crossing rings make area and\ncontainment undefined — a \"polygon\" that crosses itself does not have a\nwell-defined inside. This is detected (via intersection-point search) and\nflagged as an error-severity issue; it is not auto-repaired, because the\nrepair (commonly a zero-width buffer) can silently change which parts of the\nshape are considered interior, and that decision belongs upstream, with\nwhoever owns the source file.\n\n**Duplicate vertex removal.** Consecutive identical vertices are collapsed;\nthey add no information and can degenerate downstream triangulation or\nedge-densification logic.\n\n**Multipolygon and hole handling.** Every ring of every part must be passed\nthrough independently — holes retain their reversed winding and are\nexcluded from containment, and every disjoint part (offshore islands,\nexclaves) must be normalized and carried forward; dropping a part because\nonly the first ring was iterated is a common but silent failure.\n\n**Invalid coordinate rejection.** Coordinates outside `[-180, 180]` longitude\nor `[-90, 90]` latitude that are not explained by an axis swap are rejected\noutright — a corrupted normalization result is worse than an explicit\nfailure.\n\n**Antimeridian splitting, polar handling, simplification, precision, and\ntemporal versioning** are each significant enough to warrant their own\ntreatment: antimeridian-crossing rings must be split rather than treated as\nplanar (see [antimeridian handling](/docs/antimeridian-handling/)); polar\ngeometry breaks the small-angle assumptions some simplification algorithms\nrely on; simplification tolerance must be recorded because it moves which\nH3 cells later qualify; coordinate precision below the target H3 resolution's\nedge length is false precision and should be capped, not trusted; and every\nnormalized geometry should be stamped with the vintage of the source file it\ncame from, because boundaries change over time even when the file format\ndoes not.\n\n## Validation checklist\n\n1. Confirm or detect source CRS; reproject to EPSG:4326 if not already.\n2. Range-check every coordinate against `[-180,180] x [-90,90]`; flag\n   suspected axis swaps (out of range, valid when reversed) as errors.\n3. Close every ring (first vertex equals last).\n4. Remove consecutive duplicate vertices.\n5. Enforce RFC 7946 winding: exterior counter-clockwise, holes clockwise.\n6. Detect self-intersections; flag as an error, do not auto-repair.\n7. Verify every ring has at least 3 distinct vertices before closure.\n8. Iterate all rings of all parts for multipolygons; do not assume a single\n   outer ring.\n9. Split any ring whose longitude span exceeds 180 degrees at the\n   antimeridian rather than passing it through as planar.\n10. Record simplification tolerance and boundary vintage on the normalized\n    output, not just on the source file.\n\n## Algorithm\n\n```ts\n\nconst result = normalizePolygon(sourceRings);\n\nif (!result.ok) {\n  // result.issues contains only error-severity codes here, e.g.\n  // \"self_intersection\", \"suspected_axis_swap\", \"out_of_range_coord\",\n  // \"too_few_vertices\" — normalization refuses to guess past these.\n  throw new Error(\n    result.issues.map((i) => `${i.code}: ${i.detail}`).join(\"; \"),\n  );\n}\n\n// result.normalized is a closed, correctly wound EPSG:4326 GeoJSON polygon.\n// result.issues may still contain warning-severity entries (e.g.\n// \"wrong_winding\", \"unclosed_ring\", \"duplicate_vertex\") describing what\n// was silently repaired — log them, don't discard them.\nfor (const issue of result.issues) {\n  console.warn(`${issue.severity} ${issue.code}: ${issue.detail}`);\n}\n```\n\n## Edge cases\n\nSelf-intersecting rings ([self-intersections](/docs/geometry-catalogue/))\nare detected via kink search and rejected rather than repaired in place.\nHoles must be verified for correct (clockwise) winding after any repair step,\nsince a hole with exterior-style winding will be read as additional covered\narea instead of an exclusion. Multipart geometries\n([multipart-geometries](/docs/geometry-catalogue/)) require iterating every\nring of every part — a normalizer that assumes one outer ring will silently\ndrop islands and exclaves. Axis-order reversal\n([axis-order-reversal](/docs/coordinate-and-crs-failures/)) is only\ndetectable, not always correctable: a coordinate that is in range in both\norderings (for example, anything within about 90 degrees of the equator on\nboth axes) cannot be disambiguated from range alone and needs a\nsource-metadata check or a bounding-box sanity test against the expected\nregion.\n\n## Assumptions and limitations\n\nNormalization assumes the source rings represent a single coherent geometry\nat one CRS; it does not attempt to merge, dissolve, or reconcile geometries\nfrom multiple sources. It also does not perform reprojection from arbitrary\nprojected CRSes in the browser bundle — it detects the symptom (coordinates\noutside the valid geographic-degree range with no valid axis-swap\nexplanation) and requires an upstream reprojection step for non-4326\nsources rather than guessing a projection to invert.\n\n## Illustration — holes are respected\n\n> Figure (polygon-hole): A square with an interior ring: normalization keeps the hole, so center-fill excludes the cells inside it."
    },
    {
      "title": "Google-Style Point-Radius Execution",
      "slug": "google-style-point-radius-execution",
      "category": "h3-to-execution",
      "summary": "Executing an H3 cell set on a platform that only accepts many independent point-and-radius targets, one circle per cell",
      "source_geometry": [
        "h3_cell_set"
      ],
      "destination_geometry": [
        "point_radius"
      ],
      "exactness": "approximate",
      "status": "stable",
      "cell_systems": [
        "h3"
      ],
      "platforms": [
        "generic"
      ],
      "edge_cases": [
        "minimum-radius",
        "radius-increments"
      ],
      "related": [
        "h3-to-circumscribed-circle",
        "platform-target-count-constraints",
        "requested-vs-executed-geography"
      ],
      "badges": [
        "Approximate",
        "Platform-dependent"
      ],
      "last_reviewed": "2026-07-22",
      "url": "https://etherdata.ai/blog/geo-interop-kb/docs/google-style-point-radius-execution",
      "markdownUrl": "https://etherdata.ai/geo-interop-kb/docs/google-style-point-radius-execution.md",
      "markdown": "approximate\nplatform-dependent\n\n## Purpose\n\nGoogle Ads, most DSPs, and most mobile geofencing SDKs share a common\nexecution primitive: a location target is a **circle** — a point plus a\nradius, subject to a platform-specific minimum radius, a rounding\nincrement, and a cap on how many targets a single campaign or ad group may\ncarry. Executing an H3 cell set on such a platform is not one conversion\nbut a repeated application of a circle choice — one circle per cell —\nsubject to those platform constraints. This page is the execution-layer\ncounterpart to the single-cell circle pages; it covers what changes when\n\"one circle\" becomes \"many circles under a budget.\"\n\n## Source geometry and destination geometry\n\nSource is an `h3_cell_set` at a stated resolution. Destination is a list\nof `point_radius` targets, one per cell (in the naive case) or fewer, after\noptimization (see below) — each entry a center, a radius, and whatever\nplatform-native target id the upload API returns.\n\n## Exactness class\n\n**Approximate**, and for two independent reasons layered on top of each\nother: first, whichever single-cell circle is chosen (inner, outer, or\nequal-area) already carries that construction's own gap or overlap\nbehavior; second, the platform's own radius rounding and minimum-radius\nfloor perturb the chosen radius again after it is computed. Never describe\nthis as \"targeting the H3 cells\" without naming both the circle mode and\nthe platform's rounding behavior.\n\n## Containment rule and boundary behavior\n\n| | |\n|---|---|\n| 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. |\n| 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. |\n| 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. |\n\n> Figure: Circumscribed circles overlap at seams → duplicate eligibility\n> Figure: Inscribed circles leave corner gaps → no overlap\n\nWhichever mode is chosen, the platform performs its own deduplication\nacross overlapping circles at the audience level — a device inside two\noverlapping outer circles is generally billed and reported once by the\nplatform's own frequency logic, not twice — but that dedup logic is\nplatform-internal and not something this conversion can verify from the\ngeometry alone. Treat `duplicate_eligibility_area`\n(`duplicateEligibilityAreaM2` on the actual circle set) as the upper bound\non the platform's exposure to double-counting, not as a guarantee of\nwhat the platform actually reports.\n\n## Resolution behavior\n\nCoarser resolutions mean fewer, larger circles — cheaper against a\ntarget-count cap, but each circle's absolute overreach or underreach grows\nwith cell size even though the relative ratio (per the single-cell circle\npages) stays roughly constant for regular hexagons. Finer resolutions mean\nmore, smaller, tighter circles that are more likely to collectively exceed\na platform's target-count cap before they exceed its accuracy needs — the\nresolution choice here is frequently constrained by the target-count limit\nfirst and the desired precision second.\n\n## Units and CRS\n\nCenters are EPSG:4326 decimal degrees; radii are meters unless the\nplatform's upload API specifies otherwise (some ad platforms accept\nradius in miles or kilometers and round differently in each unit — verify\nthe platform's documented unit before submitting, since a silent\nunit mismatch is functionally a 1.6× or 0.62× scale error on every target).\n\n## Algorithm\n\n```\nfunction executeAsPointRadius(cells, mode, platformLimits):\n    circles = []\n    for cell in cells:\n        c = computeCircle(cell, mode)          # inscribed | circumscribed | equalArea\n        r = clamp(c.radiusMeters, platformLimits.minRadius, platformLimits.maxRadius)\n        r = roundToIncrement(r, platformLimits.radiusIncrement)\n        circles.append({ center: c.center, radiusMeters: r })\n    if len(circles) > platformLimits.maxTargets:\n        circles = optimizeCircleCover(cells, platformLimits)  # advanced, see below\n    return circles\n```\n\n```ts\n\nfunction toPlatformTargets(\n  cells: string[],\n  mode: \"inscribed\" | \"circumscribed\" | \"equal_area\",\n  minRadiusM: number,\n  radiusIncrementM: number,\n) {\n  const build = mode === \"inscribed\" ? inscribedCircle\n    : mode === \"circumscribed\" ? circumscribedCircle\n    : equalAreaCircle;\n  return cells.map((cell) => {\n    const c = build(cell);\n    const rounded = Math.max(\n      minRadiusM,\n      Math.ceil(c.radiusMeters / radiusIncrementM) * radiusIncrementM,\n    );\n    return { lat: c.center[0], lng: c.center[1], radiusMeters: rounded };\n  });\n}\n```\n\nThe same conversion with the Python bindings (`h3-py` v4):\n\n```python\n\n# Reuses the same densify-and-measure routines from the inscribed/\n# circumscribed circle pages; only the aggregation (min vs max vs area)\n# and the platform clamp/round step differ per mode.\n\ndef circle_for_mode(cell: str, mode: str):\n    center = h3.cell_to_latlng(cell)\n    if mode == \"equal_area\":\n        area_m2 = h3.cell_area(cell, unit=\"m^2\")\n        return center, math.sqrt(area_m2 / math.pi)\n    boundary = h3.cell_to_boundary(cell)\n    n = len(boundary)\n    dists = []\n    for i in range(n):\n        a, b = boundary[i], boundary[(i + 1) % n]\n        for sample in densify_edge_geodesic(a, b):  # see h3-to-inscribed-circle\n            dists.append(h3.great_circle_distance(center, sample, unit=\"m\"))\n    if mode == \"inscribed\":\n        return center, min(dists) * (1 - 1e-4)\n    if mode == \"circumscribed\":\n        return center, max(dists) * (1 + 1e-4)\n    raise ValueError(f\"unknown mode: {mode}\")\n\ndef to_platform_targets(cells, mode, min_radius_m, radius_increment_m):\n    targets = []\n    for cell in cells:\n        (lat, lng), radius_m = circle_for_mode(cell, mode)\n        rounded = max(\n            min_radius_m,\n            math.ceil(radius_m / radius_increment_m) * radius_increment_m,\n        )\n        targets.append({\"lat\": lat, \"lng\": lng, \"radiusMeters\": rounded})\n    return targets\n```\n\n`h3-py` provides no per-cell circle helper for any of the three modes and\nno minimum-radius/rounding logic — both are reproduced here: the min/max\ngreat-circle-distance aggregation over a densified boundary for\ninscribed/circumscribed (identical algorithm to the dedicated circle\npages), the area-only formula for equal-area, and a plain `max`/`ceil`\nclamp-and-round for the platform floor and increment. The tested\nreference implementation is the TypeScript in `lib/`.\n\n## Parameters\n\nCircle mode (inner/outer/equal-area), the platform's minimum radius,\nmaximum radius, radius rounding increment, and maximum target count per\ncampaign/ad-group — all platform-specific and none safe to assume from\nanother platform's documented limits.\n\n## Outputs\n\nA list of `{ center, radiusMeters, platformTargetId }` entries, tagged with\nthe circle mode used and the resolution the source cell set was normalized\nto, so a later audit can reconstruct why a given radius does not exactly\nmatch `circumscribedCircle(cell).radiusMeters` (rounding, or a min-radius\nfloor, will have moved it).\n\n## Quality metrics\n\n`overreach_ratio` and `underreach_ratio` from the underlying circle mode\nstill apply per cell, plus `duplicate_eligibility_area` across the full\ntarget set once rounding has been applied — rounding up to a minimum\nradius or to the next increment always increases overreach relative to the\nraw circle, never decreases it, since the rounding direction favors\nmeeting the platform's floor.\n\n## Edge cases\n\nA [minimum-radius](/docs/platform-target-count-constraints/) floor turns a\nsmall, fine-resolution cell's already-small inscribed circle into a circle\nmuch larger than the cell itself once the floor is applied — at that point\nthe \"inner\" circle mode no longer has its containment guarantee, because\nthe platform, not this conversion, has overridden the radius. Radius\nrounding to a platform's fixed increment (for example, whole kilometers or\nquarter-miles) means the delivered radius is never exactly the computed\none; round up when the intent was outer/coverage-guaranteeing and round\ndown when the intent was inner/containment-guaranteeing, and document\nwhich direction was used, since rounding in the wrong direction silently\nflips a construction's guarantee.\n\n## Advanced: optimized circle cover\n\nWhen the naive one-circle-per-cell list exceeds a platform's target-count\ncap, an experimental alternative is to solve a small circle-cover problem\ninstead: cluster nearby cell centers, replace several small circles with\nfewer, larger ones sized to bound total overreach, and stop once the\ntarget count is under the cap. This is the `platform_limit_optimized`\nconversion profile — `containmentMode: intersect`, `circleMode:\ncircumscribed`, resolution policy \"compact and coarsen until under the cap\nand above the minimum radius,\" with the profile's own stated guarantee\nlimited to \"respects `maxTargets` and `minRadius` for the named platform\"\nand its stated tradeoff explicit: \"coarsening enlarges the effective\nfootprint.\" Treat this path as experimental and always report\n`overreach_ratio` and `coverage_ratio` against the original cell set, not\njust against the coarsened one, since the coarsening step itself is a\nsecond, compounding approximation on top of the circle choice.\n\n## Assumptions and limitations\n\nThis conversion assumes the platform's declared minimum radius, maximum\nradius, rounding increment, and target-count cap are current — these\nchange per platform and per ad product without notice, and a value cached\nfrom a prior integration can silently violate the platform's actual\ncurrent limits. It also assumes the platform's own cross-target\ndeduplication is at least as conservative as the geometry suggests;\nverify this against platform documentation rather than assuming it, since\n`duplicate_eligibility_area` computed here is a geometric upper bound, not\na report of what the platform will actually bill or attribute."
    },
    {
      "title": "H3 Cell Set to Optimized Circle Cover",
      "slug": "optimized-circle-cover",
      "category": "h3-to-execution",
      "summary": "A heuristic greedy cover that replaces a target H3 set with fewer point+radius circles under a bounded overreach — experimental, not an optimal solver.",
      "source_geometry": [
        "h3_cell_set"
      ],
      "destination_geometry": [
        "point_radius"
      ],
      "exactness": "approximate",
      "status": "experimental",
      "cell_systems": [
        "h3"
      ],
      "platforms": [
        "generic"
      ],
      "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"
      ],
      "badges": [
        "Experimental",
        "Approximate",
        "Platform-dependent"
      ],
      "last_reviewed": "2026-07-22",
      "url": "https://etherdata.ai/blog/geo-interop-kb/docs/optimized-circle-cover",
      "markdownUrl": "https://etherdata.ai/geo-interop-kb/docs/optimized-circle-cover.md",
      "markdown": "experimental approximate\n\n## Purpose\n\nOne circumscribed circle per cell ([one circle per cell](/docs/google-style-point-radius-execution/))\nis simple but produces exactly as many targets as cells — which fails when a\nplatform caps the number of targets (see\n[target-count constraints](/docs/platform-target-count-constraints/)). This\nmethod replaces the target set with **fewer, larger circles** while bounding how\nmuch extra ground each circle adds. It is a heuristic, explicitly **not** an\noptimal set-cover solver.\n\n## Source and destination geometry\n\nSource is an `h3_cell_set`; destination is a list of `point_radius` circles, one\nper selected disk, each carrying the target cells it covers.\n\n## Approximation class and boundary behavior\n\n| | |\n|---|---|\n| Exactness | Approximate — a covered cell means its CENTER lies inside a circle, not that the cell is geometrically contained. |\n| Coverage guarantee | Under the defaults (minCoverage = 1, maxOverreach = ∞) every target cell centre is covered and circleCount ≤ cellCount. |\n| Overreach | Each circle adds area beyond the cells it covers; the local overreach is bounded by maxOverreach. Report it — this is not exact execution. |\n| Monotonicity | A tighter overreach cap never yields fewer circles than a looser one. |\n\n## Algorithm\n\nGreedy weighted set-cover over candidate disks grown from each cell's centre:\n\n```text\nuncovered = all target cells\nwhile uncovered and coverage < minCoverage:\n  for each seed cell, for k in 0..maxK:\n    disk = circle at seed centre reaching the k-ring cell centres\n    covered = target cells whose centre is within the disk radius\n    localOverreach = (π r² − Σ area(covered)) / Σ area(covered)\n    skip if localOverreach > maxOverreach\n    score = |covered ∩ uncovered| / (1 + localOverreach)\n  pick the highest-scoring disk; remove its covered cells from uncovered\n```\n\nDistances are spherical (haversine) metres; areas come from h3 `cellArea` — the\nsame spherical model as the [circle](/docs/h3-to-inscribed-circle/) methods, so\nradii and areas are mutually consistent. The search is O(cells² · maxK); it is\nintended for target sets of hundreds to low thousands of cells, not millions.\n\n```ts\n\n// Fit a blob of cells under a target cap, accepting up to 1.5x local overreach.\nconst cover = optimizedCircleCover(cells, { maxOverreach: 1.5, maxK: 3 });\n// cover.circles: [{ center: [lat,lng], radiusMeters, coveredCells, localOverreach }]\n// cover.circleCount vs cover.cellCount, cover.coverageRatio, cover.uncovered\n```\n\nThe tested reference implementation is the TypeScript in `lib/h3/optimized-cover.ts`.\nAn equivalent sketch with the Python bindings (`h3-py` v4) would grow disks from\n`h3.cell_to_latlng` centres and measure with `h3.great_circle_distance` and\n`h3.cell_area`, applying the same greedy rule.\n\n## Conservative vs expansive\n\n- **Expansive** (higher `maxOverreach`): fewer, larger circles; more duplicate\n  eligibility and boundary spill. Pair with an explicit overreach report.\n- **Conservative** (lower `maxOverreach`, or seed from\n  [inscribed circles](/docs/h3-to-inscribed-circle/)): more circles, less spill,\n  closer to the target footprint.\n\n## Parameters\n\n| | |\n|---|---|\n| maxK | Neighbourhood radius (grid rings) for disk growth. Default 3. |\n| maxOverreach | Reject candidate disks whose local overreach exceeds this. Default ∞. |\n| minCoverage | Stop once this fraction of cells is covered. Default 1 (all). |\n\n## Outputs and quality metrics\n\nOutputs: the circle list plus `circleCount`, `cellCount`, `coverageRatio`, and\n`uncovered`. Compute [overreach and duplicate-eligibility](/docs/conversion-quality-metrics/)\non the returned circles before executing — the reduction in target count is\npaid for in overreach, and both numbers must travel with the result.\n\n## Known limitations\n\n- Heuristic, not optimal — a smaller admissible cover may exist.\n- Coverage is defined on cell centres; a covered cell can still have corner area\n  outside its covering circle. For a hard geometric guarantee, seed radii from\n  [circumscribed circles](/docs/h3-to-circumscribed-circle/) instead.\n- Ignores platform [minimum radius](/docs/platform-target-count-constraints/) and\n  radius increments; clamp and re-measure afterwards.\n- No antimeridian handling in the greedy step — split trans-antimeridian sets\n  first (see [antimeridian handling](/docs/antimeridian-handling/))."
    },
    {
      "title": "H3 Compaction And Uncompaction",
      "slug": "h3-compaction-and-uncompaction",
      "category": "semantics",
      "summary": "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.",
      "source_geometry": [],
      "destination_geometry": [],
      "exactness": "exact",
      "status": "stable",
      "cell_systems": [
        "h3"
      ],
      "platforms": [
        "generic"
      ],
      "edge_cases": [
        "parent-child-duplicates",
        "mixed-resolutions"
      ],
      "related": [
        "mixed-h3-resolutions",
        "resolution-selection"
      ],
      "badges": [
        "Exact"
      ],
      "last_reviewed": "2026-07-22",
      "url": "https://etherdata.ai/blog/geo-interop-kb/docs/h3-compaction-and-uncompaction",
      "markdownUrl": "https://etherdata.ai/geo-interop-kb/docs/h3-compaction-and-uncompaction.md",
      "markdown": "exact\n\n## Purpose\n\nCompaction is a lossless rewrite of an H3 cell set: wherever a\nresolution-`r` cell's complete set of seven children at resolution `r+1` is\npresent in the set, those seven children are replaced by the single parent\ncell. Uncompaction is the exact inverse: every cell in a (possibly mixed-\nresolution) set is expanded down to a single stated resolution. Both\noperations exist for two reasons — reducing the cell count needed to\nrepresent a target for storage, transmission, or platform target-count\nlimits, and producing rollup summaries at a coarser resolution without\nre-deriving them from source geometry.\n\n## Compaction\n\nCompaction operates purely on the H3 index hierarchy, not on the geometry the\ncells represent. Given a cell set, it repeatedly checks: for a candidate\nparent cell at resolution `r`, are all seven of its resolution-`r+1` children\npresent in the set? If so, remove all seven children and insert the parent.\nThis check cascades upward — a newly inserted parent may itself complete\n*its* parent's set of seven, and so on — so a fully compacted set can contain\ncells from many different resolutions at once, each representing the\ncoarsest complete grouping available in that part of the set. A cell set\nwith no complete sibling groups compacts to itself unchanged; compaction\nnever removes coverage, it only changes how completely-covered regions are\nindexed.\n\n> **Note:** \"Complete set of seven children\" is a statement about the H3 indexing\nhierarchy, established by `cellToChildren` / `cellToParent`, not a statement\nabout the children's polygons tiling the parent's polygon with zero gap or\noverlap in physical space. The two agree closely in practice but are not\ndefined to be identical — see the containment caveat on\n[mixed H3 resolutions](/docs/mixed-h3-resolutions/).\n\n## Uncompaction\n\nUncompaction reverses this: every cell in the input, regardless of its\ncurrent resolution, is expanded via `cellToChildren` down to the single\ntarget resolution requested. A cell already at the target resolution passes\nthrough unchanged; a coarser cell is expanded into all of its descendants at\nthat resolution. The output is always a uniform-resolution set, which is why\nuncompaction is the standard first step before running any per-cell\naggregation, area estimate, or comparison against another uniform-resolution\nset — see [mixed H3 resolutions](/docs/mixed-h3-resolutions/) for why skipping\nthis step produces double-counted or incomparable results.\n\n## The round-trip property\n\n```\nuncompact(compact(S), r) == S\n```\n\nfor any cell set `S` that is already uniform at resolution `r`. This is the\nproperty that makes compaction safe for storage: compacting a set before\nwriting it and uncompacting it back to the original resolution on read must\nreproduce the exact original set, cell for cell, with no loss and no drift.\nThis round trip is a tested invariant — property-based tests generate\nuniform-resolution cell sets (including adversarial ones seeded near\npentagons and face-crossing cells), compact them, uncompact back to the\noriginal resolution, and assert set equality against the input on every run.\nAn implementation that fails this property is not an acceptable trade-off,\nit is broken.\n\n> **Note:** `uncompact(compact(S), r)` reproduces `S` only when `r` is the resolution `S`\nwas uniform at before compaction. Uncompacting a compacted set to a coarser\nresolution than the original discards information (folding fine detail\nupward loses the finer partition); uncompacting to a finer resolution than\nthe original manufactures cells that were never in the source set. Always\nuncompact to the resolution the set was compacted from unless the intent is\na deliberate resolution change, in which case use\n[normalizeToResolution](/docs/mixed-h3-resolutions/) and treat it as a\nresolution conversion, not a round trip.\n\n## Use cases\n\nCompaction is the standard technique for **target-count optimization**: many\nplatforms cap the number of discrete geo targets accepted per campaign, and a\ncompacted set expresses the same effective geography in far fewer rows\nwhenever the source geometry contains large, uniformly-covered interior\nregions (a full county polyfilled at resolution 9 compacts to a small number\nof resolution-6 or resolution-5 cells for its interior, with only the\nboundary remaining at finer resolution). It is equally the standard technique\nfor **reporting rollups**: a delivery report aggregated at resolution 6 can\nbe produced directly by compacting resolution-9 delivery data, rather than\nre-querying source geometry at the coarser resolution.\n\n## Algorithm\n\n```ts\n\n// Reduce row count for storage / platform target-count limits.\nconst compacted = compact(uniformResolution9Cells);\n\n// Recover the exact original set — property-tested round trip.\nconst restored = uncompact(compacted, { resolution: 9 });\n// restored is set-equal to uniformResolution9Cells\n```\n\nThe same conversion with the Python bindings (`h3-py` v4):\n\n```python\n\n# Reduce row count for storage / platform target-count limits.\ncompacted = h3.compact_cells(uniform_resolution_9_cells)\n\n# Recover the exact original set — round-trip invariant, not a best effort.\nrestored = h3.uncompact_cells(compacted, 9)\nassert set(restored) == set(uniform_resolution_9_cells)\n```\n\nThe tested reference implementation in this knowledge base is the\nTypeScript in `lib/`, which asserts this round trip as a property-based\ntest (including adversarial cell sets seeded near pentagons and\nface-crossing cells) rather than checking it once by hand.\n\n> Figure (mixed-resolution): Compaction replaces a full child set with its parent; parent (amber) vs children (green) footprints differ only logically.\n\n## Parameters\n\nFor `compact`: none beyond the input cell set — the algorithm always\ncompacts maximally. For `uncompact`: the target resolution, which must be\ngreater than or equal to the coarsest cell present in the compacted input, or\nthe operation is undefined for any cell coarser than the requested target.\n\n## Outputs\n\n`compact` returns a mixed-resolution cell set, typically substantially\nsmaller in cell count than the input for geometries with large uniform\ninteriors. `uncompact` returns a uniform-resolution cell set at the requested\nresolution, set-equal to the pre-compaction input when uncompacted back to\nthe original resolution.\n\n## Edge cases\n\n[Parent-child duplicates](/docs/mixed-h3-resolutions/) are what a broken or\npartial compaction leaves behind — a set that replaced some but not all of a\nsibling group, or that was merged with another set after compaction without\nre-checking for completed groups, ends up with both a parent and some of its\nchildren present simultaneously; `hasParentChildDuplicate` should be run on\nany compacted set before it is trusted as fully compacted.\n[Mixed resolutions](/docs/mixed-h3-resolutions/) are the expected, correct\noutput shape of `compact` itself — the presence of multiple resolutions in a\ncompacted set is not a defect, but it does mean the set must be uncompacted\n(or otherwise normalized) before any operation that assumes a uniform\nresolution.\n\n## Assumptions and limitations\n\nCompaction assumes the input cell set is already uniform at one resolution;\nrunning `compact` on an already-mixed set (rather than uncompacting first)\nis only correct if the input is known to already reflect a valid partial\ncompaction — otherwise sibling groups that exist across the mixed boundary\nmay go undetected. Compaction reduces cell *count*; it does not change the\ngeography represented, and it provides no benefit when the source geometry\nhas no large uniformly-covered interior regions (a thin corridor or a\nboundary-heavy shape compacts to nearly its original size).\n\n## Illustration — compaction shrinks the set, not the footprint\n\n> Figure: before: 34 cells, all R8\n> Figure: after: 22 cells, mixed R6/R7/R8"
    },
    {
      "title": "H3 Overview",
      "slug": "h3-overview",
      "category": "systems",
      "summary": "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.",
      "source_geometry": [
        "h3_cell_set"
      ],
      "destination_geometry": [
        "h3_cell_set"
      ],
      "exactness": "approximate",
      "status": "stable",
      "cell_systems": [
        "h3"
      ],
      "platforms": [
        "generic"
      ],
      "edge_cases": [
        "pentagons",
        "face-crossing-cells",
        "mixed-resolutions"
      ],
      "related": [
        "h3-pentagons",
        "cell-system-comparison",
        "mixed-h3-resolutions"
      ],
      "badges": [
        "Approximate"
      ],
      "last_reviewed": "2026-07-22",
      "url": "https://etherdata.ai/blog/geo-interop-kb/docs/h3-overview",
      "markdownUrl": "https://etherdata.ai/geo-interop-kb/docs/h3-overview.md",
      "markdown": "approximate\n\n## What H3 is\n\nH3 is a discrete global grid system developed by Uber. It projects a\nregular icosahedron (20 triangular faces) onto the sphere using a gnomonic\nprojection per face, then subdivides each face hierarchically to produce a\ngrid of predominantly hexagonal cells at 16 resolutions, numbered 0\n(coarsest) through 15 (finest). This knowledge base treats H3 as the\ndefault interoperability grid for advertising execution elsewhere, but\nthis page describes H3 strictly as a generic cell system, on the same\nterms as [S2](/docs/s2-overview/) and [Geohash](/docs/geohash-overview/) —\nwithout assuming its properties are universal (see\n[cell-system-comparison](/docs/cell-system-comparison/)).\n\n## Hierarchy: aperture 7\n\nH3's subdivision scheme is \"aperture 7\": each cell at resolution N is\ncovered by approximately 7 cells at resolution N+1. This is a logical,\nindex-arithmetic relationship, not an exact geometric tiling — a parent\nhexagon's true boundary and the union of its 7 child cells' true\nboundaries are close but not identical, because the aperture-7 subdivision\nis not an exact area-7 partition of a hexagon. This is the single most\nimportant caveat distinguishing H3 from S2: S2's quad hierarchy gives\nexact geometric containment (4 children exactly tile their parent), while\nH3's aperture-7 hierarchy does not. Treating an H3 parent cell as if it\nexactly geometrically contains its children is a documented source of\nsilent boundary error — see\n[mixed-h3-resolutions](/docs/mixed-h3-resolutions/) for the operational\nconsequences when a single analysis mixes cells from more than one\nresolution.\n\n## Resolutions and cell size\n\nCell area shrinks by roughly a factor of 7 per resolution step, and edge\nlength by roughly √7. Because H3 cells are gnomonic projections of a\ntriangular icosahedron subdivision rather than an equal-area construction,\naverage cell size is the only meaningful number per resolution — actual\narea varies by roughly 2x across the globe at a fixed resolution, largest\nnear face centers and most distorted near face edges and vertices.\n\n| Resolution | Avg edge length (km) | Avg cell area (km²) |\n|---|---|---|\n| 0 | 1107.71 | 4,250,546.8 |\n| 1 | 418.68 | 607,220.9 |\n| 2 | 158.24 | 86,745.9 |\n| 3 | 59.81 | 12,392.3 |\n| 4 | 22.61 | 1,770.3 |\n| 5 | 8.54 | 252.9 |\n| 6 | 3.23 | 36.1 |\n| 7 | 1.22 | 5.16 |\n| 8 | 0.461 | 0.737 |\n| 9 | 0.174 | 0.105 |\n| 10 | 0.0659 | 0.0150 |\n| 11 | 0.0249 | 0.00215 |\n| 12 | 0.00942 | 0.000307 |\n| 13 | 0.00356 | 0.0000439 |\n| 14 | 0.00135 | 0.0000063 |\n| 15 | 0.00051 | 0.0000009 |\n\nThese are the published average values (h3geo.org, last verified\n2026-07-22); treat them as a planning reference, not a per-cell guarantee\n— any individual cell at a given resolution can differ from the average\nby roughly a factor of 2 due to icosahedron projection distortion.\n\n## Hexagons, and the 12 pentagons\n\nH3 cells are hexagons almost everywhere, but exactly 12 cells per\nresolution are pentagons — one at each of the icosahedron's 12 vertices,\nwhere the underlying polyhedron cannot be tiled with hexagons alone\n(Euler's formula forces at least 12 pentagonal defects on any hexagonal\ntiling of a sphere-like surface). Pentagon cells have 5 neighbours instead\nof 6, distorted area and shape relative to neighbouring hexagons, and\nrequire explicit handling in any code that assumes \"a cell has 6\nneighbours\" as a universal invariant. See\n[h3-pentagons](/docs/h3-pentagons/) for the full treatment, including\nwhich resolutions and coordinates the 12 base pentagons fall at and how\nthey propagate to every finer resolution.\n\n## Index representation\n\nEach H3 cell is addressed by a 64-bit integer, conventionally rendered as\na 15-character hexadecimal string. The index encodes the resolution, the\nbase cell (one of 122 base cells at resolution 0), and a sequence of\nper-resolution digit values describing the path down the hierarchy to the\nspecific cell. Index bits are not simply concatenated lat/lng bits the way\na Geohash string is — H3 index arithmetic is specific to H3's own\nsubdivision scheme and does not decode meaningfully by an external system.\n\n## Core operations\n\n| | |\n|---|---|\n| Point indexing | `latLngToCell(lat, lng, res)` maps a coordinate to its containing cell index at a given resolution. |\n| 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.' |\n| 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). |\n| 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. |\n| 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/). |\n\n## What must not be assumed\n\nH3 is **not** equal-area: cell area varies by roughly 2x globally at a\nfixed resolution, so per-cell counts or densities must be area-normalized\nbefore comparison across regions. H3's logical parent/child containment is\n**not** exact geometric containment — a child cell can, in principle,\nextend slightly past its logical parent's true boundary, which matters for\nany operation assuming coarsening a cell set to a parent resolution\nreproduces the same covered region exactly; code needing exact geometric\ncontainment should use S2 instead, since S2's quad children exactly tile\ntheir parent. Neither property is implementation-specific; both are\nstructural consequences of a hexagon-dominant grid on a gnomonic\nicosahedron projection.\n\n## References\n\n- H3 Documentation — Uber, [h3geo.org](https://h3geo.org/) (last verified\n  2026-07-22)\n\n## Assumptions and limitations\n\nThe resolution/edge-length table above states average values under the\nsystem's own published geometry; treat individual-cell deviations,\nexact pentagon vertex coordinates, and library-version-specific behavior\nas requiring direct verification against `h3-js` (or the equivalent H3\nbinding in use) rather than this table before a production decision.\n\n## Illustration — a real H3 cell\n\n> Figure (cell-h3): An actual R9 H3 cell (h3-js): a hexagon with 6 neighbours — except at the 12 pentagons per resolution."
    },
    {
      "title": "H3 Pentagons",
      "slug": "h3-pentagons",
      "category": "systems",
      "summary": "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.",
      "source_geometry": [
        "h3_cell_set"
      ],
      "destination_geometry": [
        "h3_cell_set"
      ],
      "status": "stable",
      "cell_systems": [
        "h3"
      ],
      "platforms": [
        "generic"
      ],
      "edge_cases": [
        "pentagons",
        "face-crossing-cells"
      ],
      "related": [
        "h3-to-inscribed-circle",
        "h3-to-circumscribed-circle",
        "cell-system-comparison"
      ],
      "badges": [],
      "last_reviewed": "2026-07-22",
      "url": "https://etherdata.ai/blog/geo-interop-kb/docs/h3-pentagons",
      "markdownUrl": "https://etherdata.ai/geo-interop-kb/docs/h3-pentagons.md",
      "markdown": "## Purpose\n\nH3 is built by projecting a hexagonal grid onto an icosahedron and wrapping\nthat onto the sphere. An icosahedron has 12 vertices, and a hexagonal grid\ncannot tile a surface with vertex curvature without a defect at each vertex\n— so every H3 resolution has exactly 12 pentagon cells, one at each\nicosahedron vertex, for every resolution from 0 to 15. This page documents\nwhat breaks when code written for \"an H3 cell\" implicitly means \"an H3\nhexagon,\" and states the explicit handling required.\n\n## What breaks\n\n| | |\n|---|---|\n| 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. |\n| 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. |\n| 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. |\n| 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. |\n\n> **Note:** Any function that computes a circle, a neighbour ring, or a shape-regularity\nmetric from an H3 index must check `isPentagon(cell)` before applying a\nhexagon-derived constant. Code that hardcodes `cos(30°)` as the\ninscribed:circumscribed ratio, or that assumes `gridDisk(cell, 1)` always\nreturns 7 cells (self plus 6), will produce silently wrong results at\nexactly 12 locations per resolution — rare enough in ad hoc testing to pass\nreview, common enough in global-coverage production data to appear in every\nlarge campaign.\n\n## Detection\n\n`isPentagon(cell)` (h3-js v4) returns whether a given cell index is one of\nthe 12 pentagons at its resolution. `getPentagons(resolution)` returns the\nfull list of the 12 pentagon cell indexes at that resolution directly,\nuseful for pre-flagging a dataset before any per-cell shape computation runs\nrather than checking every cell individually.\n\n```ts\n\nconst pentagonsAtRes8 = getPentagons(8); // exactly 12 cell indexes\nconst flagged = cellSet.map((cell) => ({\n  cell,\n  isPentagon: isPentagon(cell),\n}));\n```\n\nThe same enumeration with the Python bindings (`h3-py` v4):\n\n```python\n\npentagons_at_res8 = h3.get_pentagons(8)   # exactly 12 cell ids\nflagged = [(c, h3.is_pentagon(c)) for c in cell_set]\n```\n\n## Where the 12 pentagons fall — land vs water\n\nThe icosahedron underlying H3 is deliberately oriented so its 12 vertices — and\ntherefore all 12 pentagons — sit in the ocean. This is not folklore: locating\n`getPentagons(res)` for every resolution and testing each cell **center** against\nNatural Earth 110m land shows **all 12 pentagon centers are over water at every\nresolution 0 through 15** (zero land centers). The choice keeps the pentagon\ndistortion off inhabited, high-inventory geography.\n\n> 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.\n\nThe nuance is footprint, not center. A resolution-0 pentagon spans thousands of\nkilometres, so even with its center in open water its **boundary** can clip a\ncoastline. Flagging whether each pentagon's boundary intersects land (still\nNatural Earth 110m) shows the effect shrinking as cells shrink:\n\n| | |\n|---|---|\n| Centers over land (all resolutions) | 0 of 12 — the design guarantee. |\n| Boundary clips land · R0 | 8 of 12 — the base pentagons are enormous. |\n| Boundary clips land · R1 / R2 | 5 / 4 of 12. |\n| Boundary clips land · R3 / R4 | 2 / 2 of 12. |\n| Boundary clips land · R5 and finer | 0 of 12 — small enough to sit entirely in open water. |\n\nSo from roughly R5 onward the 12 pentagons are fully offshore; only at very\ncoarse resolutions (R0–R4) does a pentagon footprint touch land at all (at R2,\nthe four that clip a coast fall on Norway, the Bohai/Yellow Sea coast, western\nAustralia, and the Argentine shelf). The full per-resolution table with every\ncell id, center, and both flags is generated to `public/fixtures/pentagons.json`\nby `scripts/gen-pentagons.ts`.\n\n```ts\n// How the flags are produced (see scripts/gen-pentagons.ts):\n\nconst rows = getPentagons(res).map((cell) => {\n  const [lat, lng] = cellToLatLng(cell);\n  const onLand = land.features.some((f) =>\n    turf.booleanPointInPolygon(turf.point([lng, lat]), f),\n  );\n  return { cell, lat, lng, centerOnLand: onLand };\n});\n```\n\n```python\n# Equivalent with h3-py v4 + shapely (land = a shapely prepared land geometry):\n\nfrom shapely.geometry import Point\n\ndef pentagons_land_flags(res, land):\n    rows = []\n    for cell in h3.get_pentagons(res):\n        lat, lng = h3.cell_to_latlng(cell)\n        rows.append({\"cell\": cell, \"lat\": lat, \"lng\": lng,\n                     \"center_on_land\": land.contains(Point(lng, lat))})\n    return rows\n```\n\n> **Note:** Classification uses Natural Earth 1:110m land, which omits small islands, so\n\"water\" means \"not on a 110m landmass,\" not \"provably open ocean.\" A pentagon\ncenter near a small island could read as water. For land-sensitive work re-run\n`gen-pentagons.ts` against a finer land polygon.\n\n## Effects on downstream conversions\n\n- **Circle approximation** ([h3-to-inscribed-circle](/docs/h3-to-inscribed-circle/),\n  [h3-to-circumscribed-circle](/docs/h3-to-circumscribed-circle/)): both\n  `inscribedCircle` and `circumscribedCircle` compute radius from the true,\n  great-circle-densified boundary — min geodesic distance for inscribed, max\n  for circumscribed — rather than from a nominal edge length, which is the\n  only reason they remain correct on pentagons at all. Any circle function\n  that instead derives radius from edge length times a hexagon constant will\n  under- or over-state the inscribed/circumscribed radius for the 12\n  pentagon cells at each resolution.\n- **Polyfill**: `polygonToH3` in `full` or `intersect` mode treats a\n  pentagon cell like any other cell for containment testing — the boundary\n  ring is simply five vertices instead of six — so polyfill correctness is\n  unaffected. What is affected is any post-polyfill shape-regularity\n  assumption applied uniformly across the result set.\n- **Neighbour operations**: `gridDisk`, `gridRingUnsafe`, and compaction\n  logic that assumes a fixed ring size per k-distance will return fewer\n  cells at a k-ring centered on or adjacent to a pentagon, because a\n  pentagon has 5 immediate neighbours, not 6, and the standard ring-size\n  formula (`3k² + 3k + 1` for a hexagon-only disk) does not hold once a\n  pentagon is inside the disk radius.\n\n## Face-crossing cells (a related, distinct distortion)\n\nPentagon cells sit at icosahedron vertices; a second, related distortion —\n[face-crossing-cells](/docs/cell-system-comparison/) — affects ordinary\nhexagon cells whose area happens to straddle two icosahedron faces. These\ncells are not pentagons and pass `isPentagon` as false, but their edges are\nasymmetric and their inscribed:circumscribed ratio deviates from the\nresolution norm for the same underlying reason: local projection distortion\nnear a geometric singularity of the icosahedron. Detection for this case is\nnot a boolean flag but a comparison of the cell's actual inscribed and\ncircumscribed radii against the resolution's typical hexagon values — a\nratio significantly below the hexagon norm indicates a face-crossing or\notherwise distorted cell even when `isPentagon` returns false.\n\n## Guidance\n\nHandle pentagons explicitly rather than filtering them out: they are valid,\npermanent members of every resolution's cell set (12 per resolution, not an\nerror condition), and any campaign with sufficient geographic scope will\ninclude one eventually. Their centers all fall over water (see the land/water\nsection above), and from resolution 5 onward their footprints are fully\noffshore, so pentagons rarely coincide with dense land inventory — but vertex\nplacement is fixed by the icosahedron construction, so a Pacific, polar, or\ncoastal cell set can still contain one. Report per-cell shape regularity (the computed\ninscribed:circumscribed ratio) alongside any circle or area conversion\nrather than assuming a resolution-level constant, and never hardcode the\nhexagon ratio of cos(30 degrees) in a function that will also receive\npentagon input.\n\n## Assumptions and limitations\n\nThis page assumes h3-js v4 semantics for `isPentagon` and `getPentagons`."
    },
    {
      "title": "H3 To Administrative Crosswalk",
      "slug": "h3-to-administrative-crosswalk",
      "category": "crosswalks",
      "summary": "Preserving every cell-to-region relationship a boundary crossing creates, instead of collapsing a straddling H3 cell to a single administrative owner.",
      "source_geometry": [
        "h3_cell_set"
      ],
      "destination_geometry": [
        "admin_county",
        "census_geo",
        "dma"
      ],
      "exactness": "weighted",
      "status": "stable",
      "cell_systems": [
        "h3"
      ],
      "platforms": [
        "generic"
      ],
      "edge_cases": [
        "duplicated-region-ids",
        "stale-boundaries"
      ],
      "related": [
        "administrative-polygon-to-h3",
        "conversion-quality-metrics"
      ],
      "badges": [
        "Weighted"
      ],
      "last_reviewed": "2026-07-22",
      "url": "https://etherdata.ai/blog/geo-interop-kb/docs/h3-to-administrative-crosswalk",
      "markdownUrl": "https://etherdata.ai/geo-interop-kb/docs/h3-to-administrative-crosswalk.md",
      "markdown": "weighted\n\n## Purpose\n\nA crosswalk answers a different question than the partition built in\n[administrative polygon to H3](/docs/administrative-polygon-to-h3/): not\n\"which single region owns this cell\" but \"which regions does this cell touch,\nand by how much.\" It exists for downstream uses that need to *split* a\nquantity — population, spend, audience, screen inventory — across regions in\nproportion to real overlap, rather than force each cell into one bucket.\n\n## Source geometry and destination geometry\n\nSource geometry is an `h3_cell_set`, normalized to EPSG:4326. Destination\ngeometry is one or more administrative region types — `admin_county`,\n`census_geo` (tract or block group), or `dma` — each a polygon or\nmultipolygon keyed by a stable region id (FIPS, GEOID, DMA code).\n\n## Exactness class\n\nThis conversion is **weighted**: it does not resolve to one right answer per\ncell, but a distribution of a cell's membership across every region it\ngeometrically intersects, expressed as fractional weights. Two crosswalks\nbuilt from the same cells and region polygons but a different weighting\nbasis (raw area versus population) legitimately assign different shares of\nthe same cell to the same region, and both are correct for their stated\nbasis.\n\n## Containment rule and boundary behavior — membership rules\n\n| | |\n|---|---|\n| 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. |\n| largest-overlap | Cell assigned to whichever region holds the largest share of its area. Single-owner, area-aware rather than point-aware. |\n| any-overlap | Cell listed against every region it intersects at all, no fraction attached. Many-to-many, boolean — eligibility, not apportionment. |\n| 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. |\n| area-weighted | Every (cell, region) pair retained, weight equal to intersection_area / cell area. Assumes uniform area density inside the cell. |\n| population-weighted | Weight is region_population times (intersection_area / region_total_area). Assumes uniform population density, false near urban cores at fine resolution. |\n| audience-weighted | Same interpolation, apportioning a platform or panel audience count instead of census population — subject to the panel's own coverage bias. |\n| inventory-weighted | Weight is a count of physical or media units (screens, store fronts) intersecting the cell, apportioned by area or unit point locations. |\n| 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. |\n\n## Why the many-to-many relationship must be preserved\n\nA cell that straddles two regions is not an edge case to be resolved away —\nit is the geometric reality of overlaying a hexagonal grid on polygons whose\nboundaries were drawn without regard to that grid. Collapsing a straddling\ncell to a single `argmax` region discards the minority share entirely.\nConsider a cell split 70/30 between County A and County B: an `argmax`\ncrosswalk assigns 100% of the cell to County A, which is now **overcounted**\nby the 30% it never held, while County B is **undercounted** by the 30% it\ndid hold — in the same operation, on the same cell. Aggregated over every\nboundary cell along a county line, this is not noise but a systematic bias:\nsmaller regions sharing a long boundary with a larger neighbor lose share\nevery time, in the same direction. A many-to-many crosswalk is the only\nrepresentation that lets a caller reconstruct the true split later — a\nsingle-owner table cannot be repaired downstream once the discarded fraction\nis gone.\n\n## Resolution behavior\n\nCoarser resolutions have fewer, larger cells, so a higher fraction of the\ncells adjacent to any boundary straddle it. Finer resolutions reduce that\nfraction — cell area shrinks roughly sevenfold per step while boundary-\nadjacent cell count grows only with the boundary's length — but the fraction\nnever reaches zero at any finite resolution, since a boundary is a continuous\ncurve and the grid is discrete. A crosswalk is required at every resolution;\nwhat changes is the total misallocated area a single-owner rule would incur,\nnot whether the problem exists.\n\n## Units and CRS\n\nRegion polygons and H3 cell boundaries are normalized to EPSG:4326 before\nintersection. `intersection_area` is computed as spherical (haversine-\nconsistent) m², matching `cellArea(cell, \"m2\")`, so `cell_coverage_fraction`\nand `region_coverage_fraction` stay dimensionless ratios of comparable area\nunits rather than a mix of projected and unprojected area.\n\n## Algorithm\n\n```ts\n\n// Many-to-many: every (cell, region) pair the cell actually touches,\n// retained with an area-based weight.\nconst crosswalk = weightedCrosswalk(cells, regionPolygons, {\n  resolution: 8,\n  weightBy: \"area\", // or \"population\" | \"audience\" | \"inventory\"\n});\n\n// Single-owner reference view, DERIVED from the same overlaps —\n// never treat this as the source of truth for apportionment.\nconst primaryRegion = maxOverlapAssignment(cells, regionPolygons, {\n  resolution: 8,\n});\n```\n\nThe same conversion with the Python bindings (`h3-py` v4):\n\n```python\n\nfrom collections import defaultdict\nfrom shapely.geometry import Polygon\n\ndef cell_polygon(cell: str) -> Polygon:\n    # h3-py returns (lat, lng) pairs; shapely expects (x, y) = (lng, lat).\n    boundary = h3.cell_to_boundary(cell)\n    return Polygon([(lng, lat) for lat, lng in boundary])\n\ndef weighted_crosswalk(cells, region_polygons: dict[str, Polygon]):\n    # Many-to-many: every (cell, region) pair the cell actually touches,\n    # retained with an area-based weight.\n    rows = []\n    for cell in cells:\n        cell_poly = cell_polygon(cell)\n        cell_area = cell_poly.area\n        for region_id, region_poly in region_polygons.items():\n            overlap = cell_poly.intersection(region_poly).area\n            if overlap > 0:\n                rows.append({\n                    \"cell_id\": cell,\n                    \"region_id\": region_id,\n                    \"cell_coverage_fraction\": overlap / cell_area,\n                })\n    return rows\n\ndef max_overlap_assignment(cells, region_polygons: dict[str, Polygon]):\n    # Single-owner reference view, DERIVED from the same overlaps — an\n    # argmax over each cell's rows, not a separate source of truth.\n    best = defaultdict(lambda: (None, 0.0))\n    for row in weighted_crosswalk(cells, region_polygons):\n        cell, region, frac = row[\"cell_id\"], row[\"region_id\"], row[\"cell_coverage_fraction\"]\n        if frac > best[cell][1]:\n            best[cell] = (region, frac)\n    return {cell: region for cell, (region, _) in best.items()}\n```\n\n`shapely` here computes planar area on lat/lng coordinates, which is only\nadequate for illustration at this scale — the tested reference\nimplementation in this knowledge base is the TypeScript in `lib/`, which\nuses spherical (haversine-consistent) area throughout.\n\n> Figure (admin-crosswalk): Two adjacent regions at R8; boundary cells assigned by max overlap (blue=west, pink=east).\n\n## Parameters\n\nResolution, weighting basis (`area`, `population`, `audience`, `inventory`,\nor a supplied `probabilistic` model), region polygon vintage, and whether\n`full-containment` rows are emitted alongside partial rows.\n\n## Outputs\n\nA weighted crosswalk table: `cell_id`, `source_region_id`,\n`intersection_area`, `cell_coverage_fraction` (intersection_area divided by\ncell area), `region_coverage_fraction` (intersection_area divided by region\narea), and, when a non-area basis is requested, an added weight column\n(`population_weight`, `audience_weight`, `inventory_weight`, or\n`probability_weight`). A cell touching three regions produces three rows.\n\n## Quality metrics\n\nFor each cell, the sum of `cell_coverage_fraction` across its rows should\nequal 1.0 within floating-point tolerance (typically 1e-6) if the region set\nis a true partition; a sum below 1.0 indicates a gap under the cell, and a\nsum above 1.0 indicates overlapping region polygons — both are per-cell\ndiagnostics worth surfacing before the crosswalk ships. Compute\n`coverage_ratio`, `overreach_ratio`, and `jaccard` per region against its\nsource polygon to confirm the crosswalk's apportioned area tracks the\nregion's true area.\n\n## Edge cases\n\n[Duplicated region ids](/docs/geometry-catalogue/) occur when a multipart\nregion (an island county, a DMA split by a lake) is stored as multiple ring\nrecords sharing one id — deduplicating on region id before intersecting\ndrops coverage from the smaller part. [Stale boundaries](/docs/geometry-catalogue/)\nare the more common failure: DMA and census vintages change year over year,\nso a crosswalk built against last year's polygons misapportions every cell\nnear a moved boundary with no error raised — `boundary_vintage` must be\nrecorded and checked against what the destination expects.\n\n## Assumptions and limitations\n\nArea- and population-weighted crosswalks assume uniform density within the\nsource region for whatever is being apportioned; this degrades visibly at\ncoarse resolution near dense urban boundaries, where population is not\nremotely uniform across a county. When better information exists — a\ngravity model, foot-traffic panel, inventory point locations — prefer\n`probabilistic` or point-apportioned `inventory-weighted` over an area\ndefault.\n\n## Illustration — weighted vs argmax\n\n> 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."
    },
    {
      "title": "H3 To Circumscribed Circle",
      "slug": "h3-to-circumscribed-circle",
      "category": "h3-to-execution",
      "summary": "Approximating an H3 cell with the smallest centered disk that fully contains it, guaranteeing coverage at the cost of overlap with neighboring cells",
      "source_geometry": [
        "h3_cell_set"
      ],
      "destination_geometry": [
        "point_radius"
      ],
      "exactness": "expansive",
      "status": "stable",
      "cell_systems": [
        "h3"
      ],
      "platforms": [
        "generic"
      ],
      "edge_cases": [
        "pentagons"
      ],
      "related": [
        "h3-to-inscribed-circle",
        "h3-to-equal-area-circle",
        "google-style-point-radius-execution"
      ],
      "badges": [
        "Expansive"
      ],
      "last_reviewed": "2026-07-22",
      "url": "https://etherdata.ai/blog/geo-interop-kb/docs/h3-to-circumscribed-circle",
      "markdownUrl": "https://etherdata.ai/geo-interop-kb/docs/h3-to-circumscribed-circle.md",
      "markdown": "expansive\n\n## Purpose\n\nWhen a point-radius execution surface must guarantee that no part of a\ntargeted cell is left unreached — a proximity alert, a store-visit radius,\na coverage-complete media buy — the circumscribed circle is the correct\nconstruction. It trades the opposite direction from the inscribed circle:\nit never undershoots the cell, at the cost of claiming ground outside it\nand overlapping every neighboring cell's own circumscribed circle.\n\n## Source geometry and destination geometry\n\nSource is an `h3_cell_set`, one circle computed per cell. Destination is\n`point_radius`: a center latitude/longitude and a radius in meters, one\npair per input cell.\n\n## Definition\n\nCenter is the H3 cell center (`cellToLatLng`). Radius is the **maximum**\ngeodesic distance from that center to any point on the cell's true\nboundary. For a hexagonal cell this maximum is attained exactly at a\nvertex — unlike the inscribed case, using the vertex set here is directionally\ncorrect, not a bug — but the implementation still densifies each edge with\ngreat-circle samples before taking the maximum, because that same routine\nmust also be correct for distorted, non-regular, and pentagon cells where\nthe true maximum can, in principle, sit off-vertex under numerical\nperturbation. A relative `SAFETY_MARGIN = 1e-4` is added to the sampled\nmaximum so the guarantee holds even between samples and against\nfloating-point error in the underlying trig.\n\n## Robust algorithm\n\n```\nfunction circumscribedRadius(cell):\n    center = cellCenter(cell)\n    boundary = cellBoundary(cell)          # great-circle arcs, vertex list\n    samples = densifyGreatCircle(boundary, perEdge=64)\n    maxDist = 0\n    for p in samples:\n        d = haversineDistance(center, p)\n        if d > maxDist: maxDist = d\n    return maxDist * (1 + 1e-4)            # SAFETY_MARGIN growth\n```\n\n```ts\n\nconst approx = circumscribedCircle(cell);\n// approx.center: [lat, lng]\n// approx.radiusMeters: maximum center->boundary distance, margin-grown\n// approx.cellAreaM2, approx.circleAreaM2, approx.isPentagon\n```\n\nThe same conversion with the Python bindings (`h3-py` v4):\n\n```python\n\nDEFAULT_EDGE_SAMPLES = 64\nSAFETY_MARGIN = 1e-4\n\ndef _to_unit_vector(lat: float, lng: float):\n    lat_r, lng_r = math.radians(lat), math.radians(lng)\n    return (\n        math.cos(lat_r) * math.cos(lng_r),\n        math.cos(lat_r) * math.sin(lng_r),\n        math.sin(lat_r),\n    )\n\ndef _to_latlng(v):\n    x, y, z = v\n    return (math.degrees(math.asin(z)), math.degrees(math.atan2(y, x)))\n\ndef _slerp(a, b, t: float):\n    # Great-circle interpolation between two unit vectors — the same\n    # densify step used on the inscribed-circle page, reused here because\n    # the maximum, like the minimum, must be measured against the full\n    # densified boundary, not just the vertex list.\n    dot = max(-1.0, min(1.0, sum(ai * bi for ai, bi in zip(a, b))))\n    theta = math.acos(dot)\n    if theta == 0:\n        return a\n    sin_theta = math.sin(theta)\n    wa = math.sin((1 - t) * theta) / sin_theta\n    wb = math.sin(t * theta) / sin_theta\n    return tuple(wa * ai + wb * bi for ai, bi in zip(a, b))\n\ndef densify_edge_geodesic(a, b, samples: int = DEFAULT_EDGE_SAMPLES):\n    va, vb = _to_unit_vector(*a), _to_unit_vector(*b)\n    return [_to_latlng(_slerp(va, vb, i / samples)) for i in range(samples + 1)]\n\ndef circumscribed_radius_m(cell: str) -> float:\n    center = h3.cell_to_latlng(cell)\n    boundary = h3.cell_to_boundary(cell)\n    n = len(boundary)\n    max_dist = 0.0\n    for i in range(n):\n        a, b = boundary[i], boundary[(i + 1) % n]\n        for sample in densify_edge_geodesic(a, b):\n            d = h3.great_circle_distance(center, sample, unit=\"m\")\n            if d > max_dist:\n                max_dist = d\n    return max_dist * (1 + SAFETY_MARGIN)  # grow so the guarantee holds between samples\n```\n\n`h3-py` has no `circumscribedCircle` helper; this mirrors the same\ndensify-then-measure approach as the inscribed-circle page but takes the\n**maximum** center-to-sample `great_circle_distance` instead of the\nminimum, then grows it by the `1e-4` safety margin instead of shrinking\nit. For a regular hexagon the true maximum lands on a vertex, but the\nalgorithm still densifies every edge rather than checking only the\nvertex list, so it stays correct for pentagons and distorted cells too.\nThe tested reference implementation is the TypeScript in `lib/`.\n\n> Figure (circumscribed-circle): The circumscribed circle reaches the farthest vertex: r=205.7 m for this R9 cell.\n\n## Containment guarantee\n\nEvery point of the true H3 cell lies inside the circumscribed disk:\n`cell ⊆ circumscribed disk`. This is the mirror-image guarantee of the\ninscribed circle's `⊆` in the other direction, and it holds under the same\nspherical model and the same `1e-4` safety margin against the finite-sample\napproximation.\n\n## Resolution behavior\n\nRadius scales with cell edge length, shrinking by roughly `sqrt(7) ≈ 2.65×`\nper resolution step, same as the inscribed circle. The *relative* overreach\n— overreach area as a fraction of cell area — is resolution-invariant for\nregular hexagons, since it is a function of cell shape, not size; the\n*absolute* duplicate-eligibility area shrinks with resolution even as the\nrelative figure holds steady.\n\n## Units and CRS\n\nCenter and boundary coordinates are `[lat, lng]` (h3-js native order),\nEPSG:4326. Distances are spherical (haversine) meters on the same\n`R_MEAN = 6,371,008.8 m` sphere `cellArea` uses.\n\n## Quality metrics\n\n| | |\n|---|---|\n| 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. |\n| 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. |\n\nFor a regular hexagon, circumradius equals the cell's own \"radius\" `R`\n(center to vertex), so `circleAreaM2 = π R²` against a hexagon area of\n`(3√3/2) R² ≈ 2.598 R²`, giving a disk/cell area ratio of about `1.209` —\nconsistent with the `cos(30°) ≈ 0.866` radius ratio between this\nconstruction and the [inscribed circle](/docs/h3-to-inscribed-circle/):\n`r_in / r_out = cos(30°)` for a regular hexagon, so the two constructions\nbound the true cell area from below (0.907× cell area) and above (1.209×\ncell area) respectively, with the cell itself sitting strictly between.\n\n## Duplicate eligibility and experiment contamination\n\nBecause every circumscribed circle extends past its cell's true boundary,\nadjacent circles overlap in a band along every shared edge. Any ground in\nthat band is eligible under two (or, near a hexagon corner, three) circles\nat once. This is a measurement hazard, not merely a targeting inefficiency:\na device or household in an overlap band can be counted as reached by two\n\"different\" cells in a report, inflating apparent reach and corrupting\nper-cell frequency capping. `duplicateEligibilityAreaM2` quantifies this\ndirectly from the set of circle features actually used, rather than from a\ntheoretical hexagon — call it after generating the full circle set for a\ncampaign, not per-cell in isolation, since the metric is inherently a\nproperty of the *set*.\n\n> **Note:** If circumscribed circles are used to define treatment cells in a geo-lift\ntest, the overlap band between a treatment cell and an adjacent control\ncell means the control cell is partially exposed to the treatment circle's\nradius. This leaks treatment into control and biases the measured lift\ndownward. Use the [inscribed circle](/docs/h3-to-inscribed-circle/), not\nthis construction, whenever cells must remain mutually exclusive for\nmeasurement purposes.\n\n## Edge cases\n\n[Pentagons](/docs/h3-pentagons/) have five, shorter, less regular edges,\nand their circumradius is measured relative to a smaller, less regular\ncell area, so the overreach ratio at the 12 pentagon cells per resolution\ndeviates further from the regular-hexagon figure above — flag them\n(`isPentagon` on the result) and do not average them into a fleet-wide\noverreach estimate. Cells whose boundary crosses an icosahedron face seam\nare still handled correctly by the distance-to-boundary maximum, since the\nalgorithm makes no planarity assumption.\n\n## Assumptions and limitations\n\nThe circumscribed circle guarantees coverage but never non-overlap;\ntreating a set of circumscribed circles as a partition (for exclusive\nbudget allocation, for instance) will overcount the ground in every overlap\nband. Use `duplicate_eligibility_area` to size that overcounting before\ncommitting a media plan or a measurement design to this construction, and\nprefer the [equal-area circle](/docs/h3-to-equal-area-circle/) when the\nrequirement is area-representative reach rather than guaranteed coverage."
    },
    {
      "title": "H3 To Equal Area Circle",
      "slug": "h3-to-equal-area-circle",
      "category": "h3-to-execution",
      "summary": "Approximating an H3 cell with a disk of the same area for reach and planning estimates, with no containment guarantee in either direction",
      "source_geometry": [
        "h3_cell_set"
      ],
      "destination_geometry": [
        "point_radius"
      ],
      "exactness": "approximate",
      "status": "stable",
      "cell_systems": [
        "h3"
      ],
      "platforms": [
        "generic"
      ],
      "edge_cases": [],
      "related": [
        "h3-to-inscribed-circle",
        "h3-to-circumscribed-circle"
      ],
      "badges": [
        "Approximate"
      ],
      "last_reviewed": "2026-07-22",
      "url": "https://etherdata.ai/blog/geo-interop-kb/docs/h3-to-equal-area-circle",
      "markdownUrl": "https://etherdata.ai/geo-interop-kb/docs/h3-to-equal-area-circle.md",
      "markdown": "approximate\n\n## Purpose\n\nPlanning tools — reach estimators, budget allocators, market-sizing\ndashboards — often need a single representative circle per cell whose\n*area* matches the cell, without caring whether that circle sits inside or\noutside the true boundary. The equal-area circle is that construction. It\nis the right tool for area-weighted reach math and the wrong tool for\nanything that will be executed as a real target, because it makes no\ncontainment claim at all.\n\n## Source geometry and destination geometry\n\nSource is an `h3_cell_set`, one circle computed per cell. Destination is\n`point_radius`: a center latitude/longitude and a radius in meters, one\npair per input cell.\n\n## Definition\n\nCenter is the H3 cell center. Radius is derived directly from cell area,\nwith no boundary sampling at all:\n\n$$\nr = \\sqrt{\\frac{\\text{cell area}}{\\pi}}\n$$\n\nwhere `cell area` is `cellArea(cell, \"m2\")`, the h3-js spherical-model area\nof the true cell. No `SAFETY_MARGIN`, no edge densification, and no\ndistance-to-boundary computation is involved — this is the only one of the\nthree circle constructions that does not touch the cell's boundary\ngeometry at all.\n\n## Containment guarantee — there is none\n\n> **Note:** The equal-area circle is not a subset of the cell and the cell is not a\nsubset of the circle. Because a disk and a hexagon are different shapes,\nmatching their areas forces the disk to extend beyond the hexagon's edges\nin some directions (near the edge midpoints, where the hexagon is\n\"thinnest\" relative to a disk of the same area) while falling short of the\nhexagon's corners in others. Gaps and overlaps exist **simultaneously** in\nthe same circle, not as alternative failure modes — this is the defining\nproperty of the construction, not an edge case of it.\n\n## Resolution behavior\n\nRadius scales with `sqrt(cell area)`, and cell area shrinks by exactly 7×\nper resolution step in H3's aperture-7 hierarchy, so radius shrinks by\n`sqrt(7) ≈ 2.65×` per step — identical scaling behavior to the inscribed\nand circumscribed radii. The simultaneous-gap-and-overlap property is\nscale-invariant: it is a function of matching a disk's area to a hexagon's\narea, independent of how large either one is.\n\n## Units and CRS\n\nCenter is `[lat, lng]` (h3-js native order), EPSG:4326. `cellArea` uses\nh3-js's spherical model; the radius derived from it is therefore a\nspherical-model radius, consistent with the inscribed and circumscribed\nconstructions but not with an ellipsoidal (WGS84) area computation.\n\n## Algorithm\n\n```\nfunction equalAreaRadius(cell):\n    area = cellArea(cell)     # h3 spherical-model area, m^2\n    return sqrt(area / pi)\n```\n\n```ts\n\nconst approx = equalAreaCircle(cell);\n// approx.center: [lat, lng]\n// approx.radiusMeters: sqrt(cellAreaM2 / Math.PI)\n// approx.cellAreaM2 === approx.circleAreaM2 (by construction, up to fp error)\n// approx.mode === \"equal_area\"\n```\n\nThe same conversion with the Python bindings (`h3-py` v4):\n\n```python\n\ndef equal_area_radius_m(cell: str) -> float:\n    area_m2 = h3.cell_area(cell, unit=\"m^2\")\n    return math.sqrt(area_m2 / math.pi)\n```\n\nUnlike the inscribed and circumscribed pages, this one really is a\none-liner in both languages — `h3.cell_area` (h3-py's spherical-model\narea, same model as h3-js's `cellArea`) is the only call involved, with no\nboundary densification or safety margin. The tested reference\nimplementation is the TypeScript in `lib/`.\n\n> Figure (three-circles): inscribed (172 m) ≤ equal-area (184 m) ≤ circumscribed (206 m) for one R9 cell.\n\n## Parameters\n\nNone beyond the cell itself — there is no edge-sample count or safety\nmargin to configure, since the construction never inspects the boundary.\n\n## Outputs\n\nA center and radius per cell, with `cellAreaM2` equal to `circleAreaM2` by\nconstruction (the entire point of the method), which also means area-based\nquality metrics computed against the *cell itself* — as opposed to against\na neighbor or a requested polygon — are close to meaningless here: of\ncourse the areas match, that is the definition, not a result.\n\n## Quality metrics\n\n`coverage_ratio`, `overreach_ratio`, and `underreach_ratio` computed against\nthe true cell boundary are all simultaneously non-zero and, for a regular\nhexagon, roughly balanced: the circle covers most of the cell interior,\nmisses a thin sliver near each corner, and extends past the boundary near\neach edge midpoint by a comparable amount, so `overreach_ratio` and\n`underreach_ratio` are both small but nonzero and neither approaches the 0%\nfigure that the exact-polygon page reports or the near-10%/20%\none-directional figures the inscribed and circumscribed pages report.\n`jaccard` (area of intersection over area of union with the true cell) is\nthe single most informative summary statistic here, since it captures both\neffects at once.\n\n## Radius ordering\n\nFor a regular hexagon the three constructions in this section order as:\n\n$$\nr_{\\text{inscribed}} \\; (\\approx 0.866R) \\; \\le \\; r_{\\text{equal-area}} \\; (\\approx 0.909R) \\; \\le \\; r_{\\text{circumscribed}} \\; (= R)\n$$\n\nwhere `R` is the hexagon's circumradius. The equal-area radius sits between\nthe other two for every regular cell, which is a useful sanity check when\nvalidating a new implementation: if a computed equal-area radius falls\noutside the `[inscribed, circumscribed]` interval for a given cell,\nsomething in the area or radius computation is wrong.\n\n## Edge cases\n\nNo edge cases are tracked separately for this conversion: because the\nconstruction never samples the boundary, it is unaffected by pentagon\nirregularity, icosahedron face-crossings, or antimeridian wrapping in the\nway the boundary-sampling constructions are — `cellArea` and `cellToLatLng`\nalready handle those correctly inside h3-js. The construction's weakness is\nnot in edge handling; it is in the fundamental non-containment property\nabove.\n\n## Assumptions and limitations\n\nNever treat an equal-area circle as an execution target when the\nrequirement is \"reach exactly this cell\" or \"do not reach outside this\ncell\" — it satisfies neither. It is appropriate for aggregate planning\nmath (summed reach estimates across many cells, where the per-cell\ngaps and overlaps partially cancel across a large footprint) and\ninappropriate as the geometry actually handed to a delivery platform; for\nthat, use the [inscribed](/docs/h3-to-inscribed-circle/) or\n[circumscribed](/docs/h3-to-circumscribed-circle/) circle depending on\nwhether the platform-side risk is under- or over-targeting."
    },
    {
      "title": "H3 To Exact Polygon",
      "slug": "h3-to-exact-polygon",
      "category": "h3-to-execution",
      "summary": "Rendering an H3 cell set as its true GeoJSON boundary, with no radius approximation and no area lost or gained",
      "source_geometry": [
        "h3_cell_set"
      ],
      "destination_geometry": [],
      "exactness": "exact",
      "status": "stable",
      "cell_systems": [
        "h3"
      ],
      "platforms": [
        "generic"
      ],
      "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"
      ],
      "badges": [
        "Exact"
      ],
      "last_reviewed": "2026-07-22",
      "url": "https://etherdata.ai/blog/geo-interop-kb/docs/h3-to-exact-polygon",
      "markdownUrl": "https://etherdata.ai/geo-interop-kb/docs/h3-to-exact-polygon.md",
      "markdown": "exact\n\n## Purpose\n\nSome execution targets accept an arbitrary polygon rather than a circle or a\nplatform-native id: a DSP with a polygon-upload geofence product, an\ninternal reporting join, a map render, or an audit artifact that must show\nexactly what an H3 cell set covers. This page converts an H3 cell set to its\ntrue boundary geometry — no radius is fitted, no area is added or removed.\nIt is the reference every circle-approximation page in this section is\nmeasured against.\n\n## Source geometry and destination geometry\n\nSource geometry is an `h3_cell_set`: one or more H3 cell ids at a stated\nresolution. The destination is not one of the catalogued geometry types in\nthis knowledge base — it is a GeoJSON `Polygon` or `MultiPolygon` Feature,\nbuilt by dissolving the shared edges of adjacent cells into a single outer\nring (with inner rings for any enclosed holes). Because that output is the\n*exact* cell footprint rather than an approximating shape, it does not carry\na separate destination-geometry id; it is described in prose here rather\nthan tagged.\n\n## Exactness class\n\nThis conversion is **exact**: the resulting polygon's boundary coincides\nwith the true edges of the input cells to within floating-point precision.\nUnlike every other conversion in this section, there is no coverage/overreach\ntrade-off to report — `coverage_ratio = 1`, `overreach_ratio = 0`,\n`underreach_ratio = 0`, `jaccard = 1`, by construction, provided the output\nis not simplified afterward.\n\n## Containment rule and boundary behavior\n\nThe rule is definitional: the output polygon contains exactly the union of\nthe input cells' true spherical boundaries, no more and no less. Two\nmechanics make this correct rather than approximate:\n\n| | |\n|---|---|\n| 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. |\n| 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. |\n\nTwo coordinate-order pitfalls sit directly on this containment rule, not\nadjacent to it — get them wrong and the \"exact\" polygon is silently mangled:\n\n> **Note:** `cellToBoundary` returns vertices as `[lat, lng]` pairs — h3-js's native\norder. GeoJSON, and every downstream consumer of this polygon (turf, Mapbox,\na DSP's polygon-upload endpoint), expects `[lng, lat]`. Swapping the pair\norder rather than transposing it produces a polygon that is a mirror image\nacross the equator/prime-meridian axis, not a shifted one — it will look\nplausible on a map at low zoom and be wrong everywhere. Always call\n`cellsToMultiPolygon(cells, true)` (the `isGeoJson` flag) or transpose\nexplicitly; never assume order.\n\n## Resolution behavior\n\nResolution changes the *density* of the boundary approximation of the true\ncurved cell edges, not the containment rule. H3 cell edges are great-circle\narcs, not planar lines; a GeoJSON ring is a sequence of straight (rhumb,\neffectively planar-interpolated) segments between vertices. At coarse\nresolutions (res 4–6) a single edge can span tens of kilometers, and the\nchord between its two vertices deviates from the true great-circle arc by a\nmeasurable amount — this is real, not a rendering artifact, and matters for\nany downstream intersection test at those resolutions. At fine resolutions\n(res 9+) edges are short enough that the chord-arc deviation is sub-meter\nand usually ignorable. If a caller needs the arc itself rather than the\nchord, densify each edge with great-circle samples before emitting the ring\n(see `densifyRingGeodesic` in the inscribed/circumscribed circle pages) —\nthis trades exactness-of-vertex-count for exactness-of-shape.\n\n> Figure (h3-to-polygon): One R9 cell as a closed GeoJSON ring: 6 vertices (gold) around the center (cyan).\n\n## Units and CRS\n\nOutput is EPSG:4326, `[lng, lat]` decimal degrees, closed rings.\n`cellArea(cell, \"m2\")` and any turf area computation on the output ring are\nconsistent with each other because both assume a spherical model rather\nthan an ellipsoidal one; treat area comparisons as spherical m², not\nsurvey-grade WGS84 m².\n\n## Algorithm\n\n```ts\n\n// Single cell -> closed GeoJSON ring, [lng,lat], via explicit transpose.\nfunction cellToRing(cell: string): [number, number][] {\n  const boundary = cellToBoundary(cell) as [number, number][]; // [lat,lng]\n  const ring: [number, number][] = boundary.map(([lat, lng]) => [lng, lat]);\n  ring.push(ring[0]!); // close the ring\n  return ring;\n}\n\n// Dissolved outline for an entire cell set -> GeoJSON MultiPolygon.\nfunction cellSetToPolygon(cells: string[]): Feature<MultiPolygon> {\n  const coords = cellsToMultiPolygon(cells, true); // isGeoJson=true => [lng,lat]\n  return { type: \"Feature\", properties: {}, geometry: { type: \"MultiPolygon\", coordinates: coords } };\n}\n```\n\nThe same conversion with the Python bindings (`h3-py` v4):\n\n```python\n\n# Single cell -> closed ring, h3-py's native (lat, lng) order.\ndef cell_to_ring(cell: str) -> list[tuple[float, float]]:\n    boundary = list(h3.cell_to_boundary(cell))  # ((lat, lng), ...), open ring\n    boundary.append(boundary[0])                # close the ring explicitly\n    return boundary\n\n# Dissolved outline for an entire cell set -> LatLngMultiPoly.\ndef cell_set_to_shape(cells: list[str]):\n    return h3.cells_to_h3shape(cells, tight=True)  # LatLngMultiPoly, outer + hole rings\n```\n\nNote the ordering: `h3.cell_to_boundary` returns `(lat, lng)` pairs and, unlike\n`cellsToMultiPolygon(cells, true)` in h3-js, does not offer a `geo_json`\nflag in v4 — flip to `(lng, lat)` yourself before handing coordinates to\nGeoJSON/shapely, and `cells_to_h3shape` likewise returns `LatLngPoly`/`LatLngMultiPoly`\nobjects in `(lat, lng)` order, not GeoJSON directly. The tested reference\nimplementation for this conversion is the TypeScript in `lib/`.\n\n## Parameters\n\nThe cell set and its resolution; an optional `densify` sample count per edge\nif the caller needs great-circle-accurate arcs rather than chords; an\noptional simplification tolerance (see below).\n\n## Outputs\n\nA GeoJSON `Polygon` or `MultiPolygon` Feature, EPSG:4326, closed rings,\nright-hand winding as produced by h3-js. Interior holes appear as additional\nrings when the input set has an enclosed gap.\n\n## Quality metrics\n\nNone are meaningful in the coverage sense — `coverage_ratio`,\n`overreach_ratio`, and `underreach_ratio` are all trivially 1, 0, 0 for an\nun-simplified output. The metric worth tracking instead is vertex count\nbefore/after any simplification step, since that is the only thing that can\npush this conversion out of the exact class.\n\n## Edge cases\n\nPentagons contribute five edges instead of six to the dissolve; no special\nhandling is required for a correct ring-dissolve implementation, but a\nnaive hex-only renderer that assumes six vertices per cell will corrupt a\npentagon's ring. Cells whose true boundary crosses an icosahedron face seam\nhave a boundary vertex sequence that is still valid but locally\nnon-convex-looking near the seam; this is expected geometry, not a bug.\n[Antimeridian](/docs/antimeridian-handling/)-crossing cell sets produce rings\nwhose longitude sign flips across ±180°; a naive consumer that does not\nsplit the ring at the antimeridian will render a polygon that wraps the\nentire globe instead of the small area actually covered — split into two\npolygons at ±180° before handing the result to a renderer or a spatial\njoin that assumes a single unsplit ring.\n\n## Assumptions and limitations\n\nThis conversion assumes the caller wants the true footprint, not a\nsimplified one. Any topology simplification pass (Douglas-Peucker,\n`turf.simplify`, or a platform's own polygon-upload simplifier) trades\nexactness for vertex count and moves this conversion from `exact` to\n`approximate` — measure `coverage_ratio` and `overreach_ratio` after\nsimplification, since a \"small\" simplification tolerance in degrees can\nstill cut off or add several percent of area at coarse resolutions where\nindividual edges are long."
    },
    {
      "title": "H3 To Inscribed Circle",
      "slug": "h3-to-inscribed-circle",
      "category": "h3-to-execution",
      "summary": "Approximating an H3 cell with the largest centered disk that stays fully inside it, for platforms that only take a point and a radius",
      "source_geometry": [
        "h3_cell_set"
      ],
      "destination_geometry": [
        "point_radius"
      ],
      "exactness": "conservative",
      "status": "stable",
      "cell_systems": [
        "h3"
      ],
      "platforms": [
        "generic"
      ],
      "edge_cases": [
        "pentagons",
        "face-crossing-cells",
        "antimeridian"
      ],
      "related": [
        "h3-to-circumscribed-circle",
        "h3-to-equal-area-circle",
        "requested-vs-executed-geography"
      ],
      "badges": [
        "Conservative"
      ],
      "last_reviewed": "2026-07-22",
      "url": "https://etherdata.ai/blog/geo-interop-kb/docs/h3-to-inscribed-circle",
      "markdownUrl": "https://etherdata.ai/geo-interop-kb/docs/h3-to-inscribed-circle.md",
      "markdown": "conservative\n\n## Purpose\n\nMany execution surfaces — mobile SDKs, DOOH proof-of-play radii,\nlegacy DSPs — accept only a point and a radius, never a polygon. The\ninscribed circle is the point-radius approximation of an H3 cell that never\nclaims ground the cell does not contain: it is the correct choice whenever\nthe requirement is \"do not target outside this cell,\" including mutually\nexclusive treatment/control cells in an experiment.\n\n## Source geometry and destination geometry\n\nSource is an `h3_cell_set`, one circle computed per cell. Destination is\n`point_radius`: a center latitude/longitude and a radius in meters, one pair\nper input cell.\n\n## Definition\n\nCenter is the H3 cell center (`cellToLatLng`). Radius is the **minimum**\ngeodesic distance from that center to any point on the cell's true\nboundary — not the nearest vertex.\n\n> **Note:** The closest point on a hexagonal cell's boundary to its center is not a\nvertex — for a regular cell it is the midpoint of the nearest edge, roughly\n13% closer to the center than the nearest vertex is. If the inscribed\nradius is computed as the minimum distance to the six (or five, for a\npentagon) *vertices* rather than to the full boundary, the resulting circle\nis measurably too large: it will extend past the true edge midpoint and\ninto the neighboring cell. That breaks the entire reason to use an\ninscribed circle — the `⊆` guarantee — and does so silently, since the\nerror is small enough to pass casual visual inspection on a map. The\nboundary, not the vertex set, is the correct reference; approximate the\nboundary by densifying each edge rather than trusting the vertices alone.\n\n## Robust algorithm\n\nEach of the cell's edges is a great-circle arc between two vertices, not a\nstraight line in lat/lng space. The algorithm densifies every edge with\n`DEFAULT_EDGE_SAMPLES = 64` evenly spaced great-circle (slerp) samples,\ncomputes the haversine distance from the center to every sample, and takes\nthe minimum. A relative `SAFETY_MARGIN = 1e-4` shrink is then applied to\nthat minimum so the guarantee holds *between* the finite samples, not only\nat them — the true continuous minimum could fall slightly closer to center\nthan any single sampled point, and the margin is sized to dominate the\nobserved sub-`1e-5` inter-sample error by an order of magnitude.\n\n```\nfunction inscribedRadius(cell):\n    center = cellCenter(cell)\n    boundary = cellBoundary(cell)          # great-circle arcs, vertex list\n    samples = densifyGreatCircle(boundary, perEdge=64)\n    minDist = +infinity\n    for p in samples:\n        d = haversineDistance(center, p)\n        if d < minDist: minDist = d\n    return minDist * (1 - 1e-4)            # SAFETY_MARGIN shrink\n```\n\n```ts\n\nconst approx = inscribedCircle(cell);\n// approx.center: [lat, lng]\n// approx.radiusMeters: minimum center->boundary distance, margin-shrunk\n// approx.cellAreaM2, approx.circleAreaM2, approx.isPentagon\n```\n\nThe same conversion with the Python bindings (`h3-py` v4):\n\n```python\n\nDEFAULT_EDGE_SAMPLES = 64\nSAFETY_MARGIN = 1e-4\n\ndef _to_unit_vector(lat: float, lng: float):\n    lat_r, lng_r = math.radians(lat), math.radians(lng)\n    return (\n        math.cos(lat_r) * math.cos(lng_r),\n        math.cos(lat_r) * math.sin(lng_r),\n        math.sin(lat_r),\n    )\n\ndef _to_latlng(v):\n    x, y, z = v\n    return (math.degrees(math.asin(z)), math.degrees(math.atan2(y, x)))\n\ndef _slerp(a, b, t: float):\n    # Spherical linear interpolation between two unit vectors; this is the\n    # great-circle equivalent of a lerp, and what \"densify with great-circle\n    # (slerp) samples\" means concretely — h3-py has no built-in densify call.\n    dot = max(-1.0, min(1.0, sum(ai * bi for ai, bi in zip(a, b))))\n    theta = math.acos(dot)\n    if theta == 0:\n        return a\n    sin_theta = math.sin(theta)\n    wa = math.sin((1 - t) * theta) / sin_theta\n    wb = math.sin(t * theta) / sin_theta\n    return tuple(wa * ai + wb * bi for ai, bi in zip(a, b))\n\ndef densify_edge_geodesic(a, b, samples: int = DEFAULT_EDGE_SAMPLES):\n    va, vb = _to_unit_vector(*a), _to_unit_vector(*b)\n    return [_to_latlng(_slerp(va, vb, i / samples)) for i in range(samples + 1)]\n\ndef inscribed_radius_m(cell: str) -> float:\n    center = h3.cell_to_latlng(cell)\n    boundary = h3.cell_to_boundary(cell)  # vertices only — NOT the full boundary\n    n = len(boundary)\n    min_dist = float(\"inf\")\n    for i in range(n):\n        a, b = boundary[i], boundary[(i + 1) % n]\n        for sample in densify_edge_geodesic(a, b):\n            d = h3.great_circle_distance(center, sample, unit=\"m\")\n            if d < min_dist:\n                min_dist = d\n    return min_dist * (1 - SAFETY_MARGIN)  # shrink so the guarantee holds between samples\n```\n\n`h3-py` does not ship an `inscribedCircle` helper or a densify function —\nthis reproduces the algorithm with core calls: `cell_to_boundary` for the\nvertex list, a great-circle (slerp) densify per edge, and\n`great_circle_distance` for each sample-to-center distance, taking the\n**minimum** across all densified samples and shrinking it by the same\n`1e-4` safety margin. Do not shortcut this to \"minimum distance to a\nvertex\" — that silently produces a circle too large to guarantee\n`⊆ cell`, exactly as the danger callout above describes. The tested\nreference implementation is the TypeScript in `lib/`.\n\n> Figure (inscribed-circle): The inscribed circle touches edge midpoints, not vertices: r=171.7 m for this R9 cell.\n\n## Containment guarantee\n\nEvery point of the inscribed disk lies inside the true H3 cell:\n`inscribed disk ⊆ cell`. This holds under the spherical model used\nthroughout (haversine distance, `cellArea` on the same model) and within\nthe `1e-4` safety margin against the finite-sample approximation of the\nboundary. It is the only circle construction in this section whose\nguarantee runs in this direction.\n\n## Resolution behavior\n\nRadius scales with cell edge length, which shrinks roughly by\n`sqrt(7) ≈ 2.65×` per resolution step. Finer resolutions give proportionally\nsmaller inscribed circles and proportionally smaller *absolute* uncovered\ncorner area, but the *relative* underreach (uncovered area as a fraction of\nthe cell) is resolution-invariant for regular hexagons — it is a function\nof cell shape, not cell size.\n\n## Units and CRS\n\nCenter and boundary coordinates are `[lat, lng]` (h3-js native order),\nEPSG:4326. Distances are spherical (haversine) meters on `R_MEAN =\n6,371,008.8 m`, the same sphere `cellArea` uses, so radius and area figures\nare mutually consistent — not ellipsoidal (WGS84) survey distances.\n\n## Quality metrics\n\n| | |\n|---|---|\n| underreach_ratio | area(cell − inscribed disk) / area(cell). Always greater than zero for a hexagon; this is the uncovered-corner cost, not a defect. |\n| uncovered_area | area(cell − inscribed disk) in absolute m², useful when comparing across mixed resolutions where the ratio alone hides magnitude. |\n\nFor a perfectly regular hexagon the *radius* ratio inscribed/circumscribed\nis exactly $\\cos(30°) \\approx 0.866$ (apothem over circumradius) — real H3\ncells are only approximately regular, so measured ratios cluster near but\nnot exactly at this value, and pentagons and distorted cells sit further\nfrom it. The area comparison that matters for `underreach_ratio` is disk\narea against *cell* area, not disk against disk:\n\n$$\n\\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\n$$\n\nfor a regular hexagon — the inscribed disk covers about 90.7% of the cell,\nleaving `underreach_ratio ≈ 0.093`: roughly 9% of a regular hexagon's area,\nconcentrated in its six corners, sits outside the inscribed disk. That 9%\nis the structural cost of the `⊆` guarantee, not a rounding error, and it is\nwhat makes the inscribed circle unsuitable whenever \"complete coverage\" is\nthe actual requirement.\n\n## Edge cases\n\n[Pentagons](/docs/h3-pentagons/) have five, shorter, less regular edges;\ntheir inscribed radius is smaller relative to cell area than a hexagon's,\nso the underreach ratio is measurably worse at the 12 pentagon cells per\nresolution — flag them (`isPentagon` on the result) rather than silently\naveraging them into a fleet-wide radius estimate. Cells whose boundary\ncrosses an icosahedron face seam are handled correctly by the\ndistance-to-boundary algorithm (it makes no planarity assumption), but any\ndownstream code that assumes a \"typical\" hexagon shape for these cells will\nbe wrong. [Antimeridian](/docs/antimeridian-handling/)-crossing cells need\nlongitude unwrapped before any lat/lng-based bounding logic runs; the\nhaversine distance calculation itself is unaffected because it works in\n3-D angle terms, not planar longitude differences.\n\n## Assumptions and limitations\n\nTwo neighboring inscribed circles never overlap with each other in the\nsense that matters for experiment isolation — no ground is double-covered —\nbut they also do not tile the cell layer: uncovered gaps exist at every\ncell's corners and are not claimed by any neighbor's circle either. That\nmakes the inscribed circle the right choice for mutually exclusive\ntreatment cells and the wrong choice for \"complete coverage\" requirements,\nwhich belong on the [circumscribed circle](/docs/h3-to-circumscribed-circle/)\npage instead."
    },
    {
      "title": "H3 To Platform Native Geography",
      "slug": "h3-to-platform-native-geography",
      "category": "crosswalks",
      "summary": "Mapping H3 cells to the opaque geo IDs a platform actually accepts, and recording the confidence and gaps that mapping introduces.",
      "source_geometry": [
        "h3_cell_set"
      ],
      "destination_geometry": [
        "platform_geo_id"
      ],
      "exactness": "approximate",
      "status": "stable",
      "cell_systems": [
        "h3"
      ],
      "platforms": [
        "generic"
      ],
      "edge_cases": [
        "platform-native-ids-only",
        "stale-boundaries"
      ],
      "related": [
        "requested-vs-executed-geography",
        "platform-target-count-constraints"
      ],
      "badges": [
        "Platform-dependent"
      ],
      "last_reviewed": "2026-07-22",
      "url": "https://etherdata.ai/blog/geo-interop-kb/docs/h3-to-platform-native-geography",
      "markdownUrl": "https://etherdata.ai/geo-interop-kb/docs/h3-to-platform-native-geography.md",
      "markdown": "platform-dependent\n\n## Purpose\n\nMany execution platforms — DSPs, walled-garden ad products, DOOH networks,\nloyalty systems — do not accept geometry at all. They accept a `platform_geo_id`:\na proprietary integer, hash, or code that references a boundary the platform\nholds internally and does not expose. This page covers the adapter pipeline\nthat turns a canonical H3 cell set into the best available set of those IDs,\nand the bookkeeping required because that translation is lossy and platform\nspecific by construction.\n\n## Source geometry and destination geometry\n\nSource geometry is an `h3_cell_set` — a set of H3 cells at a stated\nresolution, normalized to EPSG:4326. Destination geometry is\n`platform_geo_id`: not a geometry at all, but an identifier drawn from a\nplatform's own geography namespace (a DMA-like market code, a proprietary\n\"zone\" id, a hashed geofence id). The distinction matters enough to restate\nplainly: **an identifier is not a geometry.** Two platforms can both expose an\nid labeled `\"zone_4471\"` and mean entirely different polygons; an id carries\nno shape, area, or boundary information on its own, only a lookup key into a\nboundary set the platform controls and can change without notice.\n\n## Exactness class\n\nThis conversion is **approximate** and platform dependent: the achievable\naccuracy is capped by whatever boundary set the platform publishes or\nlicenses, which is frequently coarser than the H3 resolution being converted\nfrom, and is never guaranteed to be a clean partition of the platform's own\nserving area. A platform's boundaries can also be entirely undocumented,\nin which case the mapping is built empirically (see\n[platform-native-ids-only](/docs/platform-target-count-constraints/) below)\nand carries lower confidence than a mapping built from a published boundary\nfile.\n\n## Adapter pipeline\n\n```mermaid\nflowchart LR\n  H[\"H3 cell set<br/>(source resolution)\"] --> U[\"Normalized polygon union<br/>(dissolve, repair, EPSG:4326)\"]\n  U --> X[\"Versioned platform<br/>geography crosswalk\"]\n  X --> P[\"Platform-native IDs<br/>(+ confidence, unmatched cells)\"]\n```\n\nThe H3 cells are first dissolved into a single normalized polygon union — not\nkept as discrete cells — because most platform boundary sets are themselves\npolygons, and polygon-to-polygon overlap is the only reliable basis for\nmatching against an opaque, externally defined geography. That union is then\nrun against a versioned crosswalk built specifically for that platform's\nboundary vintage, which resolves to zero, one, or several platform-native IDs\ndepending on how the union overlaps the platform's own zones.\n\n## Containment rule and boundary behavior\n\nA platform id is emitted for a cell (or, after dissolving, for a portion of\nthe union) when the union's overlap with that platform zone exceeds a stated\n`match_threshold` — commonly a coverage-ratio cutoff such as 0.5, meaning the\nplatform zone must account for at least half of the area under consideration\nbefore its id is included. Coverage below the threshold is recorded as a\n**partial match**, not silently dropped: it is retained with its actual\noverlap fraction so a caller can decide whether to include it. A cell (or\nunion fragment) with no platform zone above any threshold is an **unmatched\ncell**, and must appear in the output as unmatched rather than be omitted,\nsince omission is indistinguishable from \"matched with zero weight\" to a\ndownstream reader.\n\n## Resolution behavior\n\nHigher H3 resolution improves the fidelity of the union that gets matched\nagainst platform zones, but does not improve the platform side of the match —\nthe platform's own boundary vintage is the binding constraint on achievable\naccuracy. Increasing source resolution beyond the point where the union\nalready tracks the intended area tightly yields no further improvement in\nmatch quality, only more cells to dissolve.\n\n## Units and CRS\n\nBoth the H3-derived union and the platform boundary set are normalized to\nEPSG:4326 before intersection; match thresholds are computed on spherical\n(haversine-consistent) m² area, consistent with the area conventions used\nelsewhere in this knowledge base.\n\n## Algorithm\n\n```ts\n\n// 1. Dissolve the H3 cell set into a single normalized polygon union.\nconst union = normalizePolygon(dissolveCellsToPolygon(cells));\n\n// 2. Match against a versioned platform geography crosswalk.\nconst matches = weightedCrosswalk([union], platformZonePolygons, {\n  weightBy: \"area\",\n});\n\n// 3. Keep matches above threshold; carry the rest as partial/unmatched.\nconst platformIds = matches.filter((m) => m.cell_coverage_fraction >= 0.5);\nconst partialMatches = matches.filter(\n  (m) => m.cell_coverage_fraction > 0 && m.cell_coverage_fraction < 0.5\n);\n```\n\nThe same conversion with the Python bindings (`h3-py` v4):\n\n```python\n\nfrom shapely.geometry import Polygon, MultiPolygon\nfrom shapely.ops import unary_union\n\ndef cells_to_union(cells) -> Polygon | MultiPolygon:\n    # h3-py's own dissolve: a cell set -> one (multi)polygon boundary.\n    # cells_to_h3shape returns a LatLngMultiPoly; walk its polygons/rings\n    # and flip each (lat, lng) vertex to shapely's (x, y) = (lng, lat).\n    # (Attribute names below are illustrative — consult the h3-py shape\n    # API for the exact accessor on the returned LatLngMultiPoly.)\n    shape = h3.cells_to_h3shape(cells, tight=True)\n    rings = [\n        Polygon(\n            [(lng, lat) for lat, lng in poly.outer],\n            [[(lng, lat) for lat, lng in hole] for hole in poly.holes],\n        )\n        for poly in shape.polygons\n    ]\n    return unary_union(rings)\n\ndef crosswalk_to_platform(\n    union: Polygon | MultiPolygon,\n    platform_zones: dict[str, Polygon],\n    match_threshold: float = 0.5,\n    boundary_vintage: str = \"unknown\",\n):\n    # Pseudo-crosswalk: match the dissolved union against a platform's own\n    # (externally sourced) zone polygons and bucket by coverage fraction.\n    matched, partial = [], []\n    covered = None\n    for platform_id, zone_poly in platform_zones.items():\n        overlap = union.intersection(zone_poly)\n        if overlap.is_empty:\n            continue\n        coverage_fraction = overlap.area / union.area\n        record = {\n            \"platform_geo_id\": platform_id,\n            \"match_confidence\": coverage_fraction,\n            \"boundary_vintage\": boundary_vintage,\n        }\n        covered = overlap if covered is None else unary_union([covered, overlap])\n        (matched if coverage_fraction >= match_threshold else partial).append(record)\n\n    unmatched_area = union.area - (covered.area if covered is not None else 0.0)\n    return {\n        \"matched\": matched,\n        \"partial_matches\": partial,\n        \"unmatched_fraction\": unmatched_area / union.area,\n    }\n```\n\nAs with the crosswalk page above, `shapely` areas here are planar and only\nillustrative; the tested reference implementation in this knowledge base is\nthe TypeScript in `lib/`, which computes match thresholds on spherical\n(haversine-consistent) area and records `boundary_vintage` and\n`unmatched_cells` explicitly rather than deriving them ad hoc.\n\n## Parameters\n\nMatch threshold, the platform's boundary vintage and ID namespace, and\nwhether unmatched or partial-match cells should be surfaced to the caller or\nsuppressed at delivery time (they should never be suppressed silently at the\ncrosswalk-building step).\n\n## Outputs\n\nA record per H3 union fragment: the resolved `platform_geo_id` (or explicit\n`unmatched`), `match_confidence` (derived from `cell_coverage_fraction`),\nthe platform's `id_namespace`, the `boundary_vintage` of the platform file\nused, and a list of `unmatched_cells` and `partial_matches` that did not clear\nthe threshold — this list is the primary artifact a media planner needs\nbefore claiming full coverage.\n\n## Quality metrics\n\n`coverage_ratio` and `jaccard`, computed between the source H3 union and the\nunion of all matched platform zones, quantify how much of the requested\ngeography the platform can actually express. A low `coverage_ratio` with a\nlong `unmatched_cells` list is a signal to renegotiate the requested geometry\nor accept a documented shortfall, not a bug in the crosswalk itself.\n\n## Edge cases\n\nSome platforms expose [platform-native-ids-only](/docs/platform-target-count-constraints/) —\nno boundary file at all, only a picklist of ids with human-readable labels\n(\"Downtown\", \"Zone 7\"). In that case the crosswalk must be built empirically,\nby observing where the platform actually delivers against a known test\ngeometry, and every such mapping should be flagged with materially lower\nconfidence than one built from a licensed boundary file. [Stale boundaries](/docs/geometry-catalogue/)\nare a recurring failure mode here as well: platforms revise their internal\nzones without a corresponding version bump on their public documentation, so\na crosswalk's `boundary_vintage` should be re-validated on a fixed schedule,\nnot assumed durable once built.\n\n## Assumptions and limitations\n\nThis conversion assumes the platform's boundary set is knowable at all\n(published, licensed, or empirically reconstructable) and that its zones are\nstatic for the duration the crosswalk is in use — an assumption platforms do\nnot always honor. When neither holds, the honest output is a documented\n`unmatched` result rather than a best-effort id chosen without a stated\nconfidence."
    },
    {
      "title": "Inclusion And Exclusion Semantics",
      "slug": "inclusion-and-exclusion-semantics",
      "category": "semantics",
      "summary": "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.",
      "source_geometry": [],
      "destination_geometry": [],
      "exactness": "approximate",
      "status": "stable",
      "cell_systems": [
        "h3"
      ],
      "platforms": [
        "generic"
      ],
      "edge_cases": [
        "unsupported-exclusions",
        "mixed-resolutions"
      ],
      "related": [
        "mixed-h3-resolutions",
        "requested-vs-executed-geography"
      ],
      "badges": [
        "Approximate"
      ],
      "last_reviewed": "2026-07-22",
      "url": "https://etherdata.ai/blog/geo-interop-kb/docs/inclusion-and-exclusion-semantics",
      "markdownUrl": "https://etherdata.ai/geo-interop-kb/docs/inclusion-and-exclusion-semantics.md",
      "markdown": "approximate\n\n## Purpose\n\nA geographic target is rarely a single region. It is usually stated as one or\nmore inclusions minus one or more exclusions — \"target this state, but not\nthis city\" — and every layer of the stack from planning through execution has\nto agree on what that combination means before any of it can be measured. This\npage defines the effective-geography operation and the failure modes that\nshow up when it is computed carelessly, or not computed at all before\ncomparing two geographies.\n\n## The effective geography\n\n$$\n\\text{effective} = \\bigcup(\\text{inclusions}) \\setminus \\bigcup(\\text{exclusions})\n$$\n\nEvery inclusion is unioned into one set, every exclusion is unioned into a\nsecond set, and the effective target is the first set with the second\nsubtracted out. This is a set operation, not a geometric one performed\npolygon-against-polygon — both sides must already be expressed as sets of H3\ncells at a common resolution before the union and difference are evaluated,\nfor reasons covered below.\n\n## Cases\n\n| | |\n|---|---|\n| 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. |\n| 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. |\n| 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. |\n| 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. |\n| 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. |\n| 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. |\n| 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. |\n\n## Why normalization must happen first\n\nUnion and set-difference are only well-defined operations on two sets drawn\nfrom the same universe. If inclusions are H3 cells at resolution 8 and\nexclusions are a ZIP polygon that has not been polyfilled, \"subtract\" is not\na computable operation yet — there is no shared unit to subtract in. Every\ninclusion and exclusion source (polygon, point-radius, raw H3 cells at\nwhatever resolution they arrived in) must be converted into H3 cells at one\ncommon working resolution before the union or the difference is taken. Doing\nthe subtraction on raw geometry first and converting to H3 second produces a\ndifferent, non-reproducible result depending on which geometry library\nperformed the subtraction — the order matters, and normalize-then-combine is\nthe only order that is reproducible from the H3 grid alone.\n\n## Precedence, empty results, and dangling exclusions\n\nExclusion always wins: a cell present in both the inclusion union and the\nexclusion union is removed, with no configuration that reverses this\nprecedence — an \"include and exclude the same cell\" request is not\nambiguous, it resolves to excluded. An effective geography can legitimately\nbe empty (the exclusion union fully covers the inclusion union); this must\nbe surfaced as an explicit empty-result state distinct from \"no exclusions\nwere supplied,\" since a downstream system that treats both cases the same\nway will silently launch against zero geography instead of raising an error.\nA **dangling exclusion** — an exclusion whose cells never intersected any\ninclusion cell in the first place — has no effect on the result but should\nstill be reported, because it usually indicates a targeting mismatch\n(the two sides were built from misaligned assumptions about what the\ninclusion actually covers) worth surfacing even though it changed nothing.\n\n## Algorithm\n\n```ts\n\n// Normalizes every input to one resolution, then applies\n// effective = union(inclusions) - union(exclusions).\nconst effective = effectiveGeography(\n  { inclusions, exclusions },\n  { resolution: 8 }\n);\n\nif (effective.cells.length === 0) {\n  // Explicit empty-result state — not the same as \"no exclusions given.\"\n  flagEmptyEffectiveGeography(effective);\n}\n```\n\nThe same conversion with the Python bindings (`h3-py` v4):\n\n```python\n\ndef normalize_to_resolution(cells, resolution: int) -> set[str]:\n    normalized = set()\n    for cell in cells:\n        res = h3.get_resolution(cell)\n        if res == resolution:\n            normalized.add(cell)\n        elif res < resolution:\n            normalized.update(h3.cell_to_children(cell, resolution))\n        else:\n            normalized.add(h3.cell_to_parent(cell, resolution))\n    return normalized\n\ndef effective_geography(inclusions, exclusions, resolution: int = 8) -> set[str]:\n    include_set = normalize_to_resolution(inclusions, resolution)\n    exclude_set = normalize_to_resolution(exclusions, resolution)\n    # Plain Python set difference — well-defined only because both sides\n    # were normalized to the same resolution above.\n    return include_set - exclude_set\n\neffective = effective_geography(inclusions, exclusions, resolution=8)\nif not effective:\n    # Explicit empty-result state — not the same as \"no exclusions given.\"\n    flag_empty_effective_geography()\n```\n\nThe tested reference implementation in this knowledge base is the\nTypeScript in `lib/`; this Python mirrors its normalize-then-combine\norder using core `h3-py` calls plus ordinary Python set operations on\nstring cell ids.\n\n> Figure (include-exclude): A 19-cell inclusion disk minus its center: 18 effective cells (pink = excluded).\n\n## Edge cases\n\n[Unsupported exclusions](/docs/platform-target-count-constraints/) are the\nmost consequential failure mode here: when a platform cannot express an\nexclusion (no negative-targeting primitive, or a cap on the number of\nexclusion regions it accepts), it drops the exclusion rather than rejecting\nthe request, so what the platform reports as delivered geography silently\nincludes territory the requester believed was excluded. This must be\ndetected before launch by checking the platform's exclusion support against\nthe request, not discovered afterward by comparing delivery logs to intent.\n[Mixed resolutions](/docs/mixed-h3-resolutions/) between the inclusion and\nexclusion sides are common whenever one side comes from a compacted cell set\nand the other from a fixed-resolution polyfill — normalizing both sides to\none resolution, as `effectiveGeography` does internally, is mandatory before\nany comparison, union, or subtraction is attempted.\n\n## Assumptions and limitations\n\nThis model assumes exclusions are geometric — describable as a set of H3\ncells — and does not cover attribute-based exclusion (for example,\n\"exclude households on a suppression list\"), which operates on a different\naxis than geography and should not be folded into the same set-difference\nwithout first confirming the platform treats the two axes independently."
    },
    {
      "title": "Line And Corridor To H3",
      "slug": "line-and-corridor-to-h3",
      "category": "source-to-h3",
      "summary": "Four distinct ways to turn a road segment or device trajectory into H3 cells, and why GPS noise makes the choice consequential.",
      "source_geometry": [
        "line_road",
        "trajectory"
      ],
      "destination_geometry": [
        "h3_cell_set"
      ],
      "exactness": "approximate",
      "status": "stable",
      "cell_systems": [
        "h3"
      ],
      "platforms": [
        "generic"
      ],
      "edge_cases": [
        "gps-noise",
        "boundary-oscillation",
        "device-trajectory-exposure"
      ],
      "related": [
        "arbitrary-polygon-to-h3",
        "point-to-h3",
        "point-radius-to-h3",
        "conversion-quality-metrics"
      ],
      "badges": [
        "Approximate"
      ],
      "last_reviewed": "2026-07-22",
      "url": "https://etherdata.ai/blog/geo-interop-kb/docs/line-and-corridor-to-h3",
      "markdownUrl": "https://etherdata.ai/geo-interop-kb/docs/line-and-corridor-to-h3.md",
      "markdown": "approximate\n\n## Purpose\n\nRoads and device trajectories are one-dimensional (or effectively\nzero-width) geometries, which makes \"convert to H3\" ambiguous in a way\npolygons are not: a line has no interior to test cell centers against.\nThis page distinguishes four legitimate conversions — intersection,\nsampling, buffered corridor, and network-distance corridor — and covers the\ntrajectory-specific problems (noise, oscillation, dwell) that dominate in\npractice.\n\n## Source geometry and destination geometry\n\nSource geometry is `line_road` (a static road-network segment or polyline)\nor `trajectory` (an ordered, timestamped sequence of device positions).\nDestination geometry is an `h3_cell_set`, or — for trajectories — an\n**ordered** H3 sequence with per-cell entry and exit timestamps, which is a\nmaterially richer object than an unordered cell set.\n\n## Exactness class\n\nApproximate for all four variants below; a 1-dimensional line has zero\nwidth and therefore zero area, so any polygon-style containment fraction is\neither trivially zero or requires an explicit corridor width to become\nmeaningful.\n\n## Containment rule and boundary behavior\n\n| | |\n|---|---|\n| 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. |\n| 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. |\n| 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. |\n| 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. |\n\nFor trajectories specifically, the output is not just a cell set but an\n**ordered sequence**: consecutive positions map to consecutive H3 cells\n(with repeats where the device stays in one cell), each annotated with\nentry timestamp, exit timestamp, and derived dwell time. Direction of travel\nis recoverable from the sequence order and should be preserved rather than\ncollapsing the trajectory into a set before it is needed.\n\n> Figure (line-corridor): A polyline buffered to a 120 m corridor, filled at R10 (intersect): 81 cells.\n\n## Resolution behavior\n\nIntersection and sampling both produce thinner, more line-like cell sets at\nhigher resolution and thicker, more blob-like sets at lower resolution,\nsince a coarse cell straddling the line drags in area on both sides.\nBuffered-corridor coverage_ratio behaves like any polygon polyfill: it\ntightens toward the true corridor area as resolution increases. For\ntrajectories, resolution also controls dwell-time granularity — at a\nresolution where consecutive pings fall in the same cell, dwell time is\ncomputed correctly as the span between entry and exit; at a resolution\nfiner than the noise floor of the position data, a stationary device can\nappear to hop between adjacent cells purely from GPS jitter (see\nboundary-oscillation, below), fragmenting what should be one dwell event\ninto many.\n\n## Units and CRS\n\nEPSG:4326 for geometry; corridor buffer widths and network distances in\nmeters; timestamps in UTC with explicit timezone handling for any\nday-part or dwell analysis performed downstream. Network-distance\ncorridors require the routing graph's own distance units (often already\nmeters) to be reconciled with the buffer width's units before comparison.\n\n## Algorithm\n\n```ts\n\n// Intersection: cells the line touches at all\nconst intersected = polygonToH3(lineAsZeroWidthGeometry, {\n  resolution: 9,\n  mode: \"intersect\",\n});\n\n// Buffered corridor: give the line width, then polyfill normally\nconst corridor = polygonToH3(bufferLine(roadSegment, 100 /* m half-width */), {\n  resolution: 9,\n  mode: \"intersect\",\n});\n\n// Trajectory: ordered cell sequence with dwell\nfunction trajectoryToCellSequence(pings, resolution) {\n  const seq = pings.map((p) => ({\n    cell: latLngToCell(p.lat, p.lng, resolution),\n    t: p.timestamp,\n  }));\n  return collapseConsecutiveDuplicates(seq); // merges repeats into entry/exit/dwell\n}\n```\n\nThe same conversion with the Python bindings (`h3-py` v4):\n\n```python\n\nfrom shapely.geometry import LineString, Point\nfrom shapely.ops import transform\n\ndef buffer_corridor(line_lat_lng, half_width_m):\n    # Buffer the line (in an azimuthal-equidistant frame centered on its\n    # midpoint) into a corridor polygon, then polyfill it like any\n    # arbitrary polygon.\n    lats, lngs = zip(*line_lat_lng)\n    mid_lat, mid_lng = sum(lats) / len(lats), sum(lngs) / len(lngs)\n    aeqd = pyproj.CRS.from_proj4(f\"+proj=aeqd +lat_0={mid_lat} +lon_0={mid_lng} +units=m\")\n    to_aeqd = pyproj.Transformer.from_crs(\"EPSG:4326\", aeqd, always_xy=True).transform\n    to_wgs84 = pyproj.Transformer.from_crs(aeqd, \"EPSG:4326\", always_xy=True).transform\n    line_m = transform(to_aeqd, LineString([(lng, lat) for lat, lng in line_lat_lng]))\n    corridor_m = line_m.buffer(half_width_m)\n    corridor_deg = transform(to_wgs84, corridor_m)\n    return [(lat, lng) for lng, lat in corridor_deg.exterior.coords]\n\ndef corridor_to_h3(line_lat_lng, half_width_m, res):\n    ring = buffer_corridor(line_lat_lng, half_width_m)\n    return h3.polygon_to_cells(h3.LatLngPoly(ring), res)  # center containment;\n    # for intersect/full/threshold, classify candidates with shapely area\n    # overlap exactly as on the arbitrary-polygon page.\n\n# Ordered sequence for a trajectory, with grid_path/grid_distance available\n# to interpolate or measure between non-adjacent cells in the sequence.\ndef trajectory_to_cell_sequence(pings, res):\n    seq = [(h3.latlng_to_cell(p[\"lat\"], p[\"lng\"], res), p[\"t\"]) for p in pings]\n    # h3.grid_distance(a, b) reports hop count between two cells in the\n    # sequence; h3.grid_path(a, b) fills in the cells presumed traversed\n    # between two non-adjacent samples (e.g. a coarse sampling step).\n    return collapse_consecutive_duplicates(seq)\n```\n\nThe tested reference implementation for this conversion is the TypeScript\nin `lib/`; the Python above mirrors its buffer-then-polyfill and\nsequence-collapsing logic with core `h3-py` calls.\n\n## Parameters\n\nResolution, conversion variant (`intersection`, `sampling`, `buffered\ncorridor`, `network-distance corridor`), corridor half-width or network\ndistance, sampling step (sampling variant only), and — for trajectories —\na smoothing window applied before cell assignment.\n\n## Outputs\n\nFor static lines: an `h3_cell_set`, optionally with `coverage_ratio` against\na buffered reference corridor. For trajectories: an ordered list of\n`{cell_id, entry_ts, exit_ts, dwell_seconds}` records, one per contiguous\noccupancy of a cell, plus a derived direction-of-travel field between\nconsecutive distinct cells.\n\n## Quality metrics\n\nFor buffered corridors: standard `coverage_ratio`, `overreach_ratio`,\n`jaccard` against the buffer polygon. For trajectories: cell-hop rate\n(hops per minute) as a proxy for noise — an implausibly high hop rate for a\ndevice's stated speed indicates boundary oscillation rather than genuine\nmovement — and dwell-event count before and after smoothing, to quantify how\nmuch fragmentation smoothing removed.\n\n## Edge cases\n\nGPS noise is the dominant real-world problem: consumer-grade GPS has a\ntypical accuracy radius of 5-15 meters in open sky and 20-50+ meters in\nurban canyons, which is frequently larger than a res-10 or res-11 cell,\nso a stationary device's raw pings can scatter across several neighboring\ncells even with zero actual movement. Boundary oscillation compounds this\nspecifically near a cell edge: a device whose true position sits within\nnoise-distance of a cell boundary can flip between two cells ping to ping,\nmanufacturing false dwell events and false direction changes; the standard\nmitigation is trajectory smoothing (a moving average or a map-matching\nstep against the road network) applied before cell assignment, not after —\nsmoothing the cell sequence itself cannot undo damage already done by\nassigning noisy points independently. Device-trajectory-exposure is a\nprivacy and governance edge case, not a purely geometric one: an ordered,\ntimestamped cell sequence at fine resolution is a de-anonymization vector\nfor identifying home and work locations, and any pipeline producing this\noutput should apply the resolution floors and aggregation minimums\ndescribed in [privacy and minimum aggregation](/docs/privacy-and-minimum-aggregation/)\nbefore the sequence leaves a controlled environment.\n\n## Assumptions and limitations\n\nThis conversion assumes the caller has decided which of the four variants\nanswers their actual question before running it — \"does this campaign\ntouch this road\" (intersection), \"what's the footprint of a 200m buffer\naround this corridor\" (buffered corridor), or \"what did this device do\"\n(trajectory) are different questions with different correct answers, and\nnone substitutes for another. Network-distance corridors additionally\nassume a maintained, routable road graph is available; where one is not,\na buffered corridor with a conservative width is the honest fallback, not\na silent substitute presented as network-aware."
    },
    {
      "title": "Lines And Trajectories",
      "slug": "lines-and-trajectories",
      "category": "geometries",
      "summary": "Unordered polylines versus ordered, timestamped position sequences — roads and rivers on one side, device journeys on the other, with very different exposure profiles.",
      "source_geometry": [
        "line_road",
        "trajectory"
      ],
      "status": "stable",
      "cell_systems": [
        "h3"
      ],
      "platforms": [
        "generic"
      ],
      "edge_cases": [
        "gps-noise",
        "boundary-oscillation",
        "device-trajectory-exposure"
      ],
      "related": [
        "line-and-corridor-to-h3",
        "privacy-and-minimum-aggregation",
        "points",
        "arbitrary-polygons"
      ],
      "badges": [],
      "last_reviewed": "2026-07-22",
      "destination_geometry": [],
      "url": "https://etherdata.ai/blog/geo-interop-kb/docs/lines-and-trajectories",
      "markdownUrl": "https://etherdata.ai/geo-interop-kb/docs/lines-and-trajectories.md",
      "markdown": "This family covers two geometries that share a shape — a sequence of\nconnected coordinates — but differ in the one property that determines how\nthey must be handled: order. A road centerline or a river is an unordered\n(or arbitrarily ordered) polyline; traversal direction is a property of the\nthing it represents, not of the geometry file. A device trajectory is an\nordered, timestamped sequence of positions where the order **is** the\ninformation — a home→highway→store sequence and a store→highway→home\nsequence are the same three points in a different, meaningful order. That\ndifference is why trajectories carry a materially higher privacy exposure\nthan any other member of this catalogue, addressed further down.\n\n| | |\n|---|---|\n| Cardinality | Unordered for roads and rivers; strictly ordered and timestamped for trajectories |\n| Governed by | A mapping vendor for roads/transit; the recording device for trajectories |\n| Not a geometry | A route name or transit-line id, distinct from its actual path geometry |\n| Converts via | Line/corridor intersection against the H3 grid, order preserved when present |\n\n## Members\n\n| Member | Ordered? | What it represents |\n|---|---|---|\n| Road centerline | No (direction is attribute data, not sequence) | A street or highway segment |\n| Transit line | Yes (route direction matters) | A bus/rail route path |\n| River | No | A natural watercourse |\n| Utility network line | No | A pipeline, cable, or conduit run |\n| Device trajectory | Yes, plus timestamps | An individual's or vehicle's journey |\n| Journey / trip | Yes, plus timestamps | A trajectory bounded to one origin-destination trip |\n\n## Required metadata\n\n| Field | Why it's required |\n|---|---|\n| CRS | Standard EPSG:4326 normalization, as with any other geometry family |\n| Direction (lines) | Needed for transit and utility-flow semantics even though the geometry itself may be stored unordered |\n| Timestamps (trajectories) | What makes a trajectory a trajectory rather than an unordered line; without them, order is unverifiable |\n| Sampling rate (trajectories) | Determines achievable resolution and how much GPS noise to expect between fixes |\n| Consent state (trajectories) | Governs both the resolution and the retention window a trajectory may legally be processed at |\n\n## Common risks\n\n**GPS noise and boundary oscillation** affect both members, but bite\nhardest on trajectories: a noisy fix sitting near a cell edge flips the\nassigned cell back and forth between two neighbors on successive samples,\ninflating a transition count that never actually happened; roads and rivers\nsuffer the corridor-scale version of the same problem, where a path\nskimming a cell edge produces a jagged, duplicated cell sequence rather\nthan a clean corridor. Both are mitigated the same way — smoothing,\nsnapping within the accuracy radius, and debouncing repeated\nA→B→A transitions — before the sequence is trusted for corridor analysis.\n**Direction loss**: treating a transit line or utility network as\nundirected when direction is operationally meaningful (which platform\nserves which stop first, which way current or flow moves) silently drops\ninformation the geometry file never encoded in the first place; direction\nhas to be carried as attribute data alongside the line, not inferred from\nvertex order.\n\n> **Note:** An ordered, timestamped, high-resolution trajectory can uniquely identify a\nperson from a home/work pattern alone, independent of any single point's\nindividual precision. This risk does not exist for an unordered road or\nriver line, and it does not exist for a single point in isolation — it is\nspecific to the ordering and density of positions in a trajectory. Coarsen\nspace or time, drop the ordering, or aggregate to origin-destination pairs\nbefore trajectories leave a privacy boundary; see\n[privacy and minimum aggregation](/docs/privacy-and-minimum-aggregation/).\n\n## How it converts to H3\n\nBoth members convert by intersecting the line against the H3 grid — a plain\nline-to-cell intersection for roads and rivers, or a buffered-corridor fill\nwhen the line needs to be treated as a band with width — documented on\n[line and corridor to H3](/docs/line-and-corridor-to-h3/). Trajectories use\nthe same geometric intersection but additionally require the ordering and\ntimestamps to be carried through the conversion so that dwell extraction\nand sequence analysis remain possible downstream; converting a trajectory\nwithout preserving order collapses it into an unordered multipoint and\nshould be treated as a [multipoints](/docs/multipoints/) case instead, with\nthe ordering loss recorded explicitly rather than silently dropped."
    },
    {
      "title": "Mixed H3 Resolutions",
      "slug": "mixed-h3-resolutions",
      "category": "semantics",
      "summary": "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.",
      "source_geometry": [],
      "destination_geometry": [],
      "exactness": "exact",
      "status": "stable",
      "cell_systems": [
        "h3"
      ],
      "platforms": [
        "generic"
      ],
      "edge_cases": [
        "mixed-resolutions",
        "parent-child-duplicates"
      ],
      "related": [
        "h3-compaction-and-uncompaction",
        "inclusion-and-exclusion-semantics"
      ],
      "badges": [
        "Exact"
      ],
      "last_reviewed": "2026-07-22",
      "url": "https://etherdata.ai/blog/geo-interop-kb/docs/mixed-h3-resolutions",
      "markdownUrl": "https://etherdata.ai/geo-interop-kb/docs/mixed-h3-resolutions.md",
      "markdown": "exact\n\n## Purpose\n\nA mixed-resolution cell set is one that contains H3 cells from more than one\nresolution at once — a resolution-6 cell alongside a resolution-9 cell in the\nsame array. This is a normal, expected shape for data that has been through\ncompaction, hierarchical rollups, or merged from two sources built at\ndifferent working resolutions. It is not, however, a shape that any set\noperation — union, intersection, difference, equality check, or membership\ntest — can be run against correctly without normalization first. This page\ndefines why, and states the exact normalization rule.\n\n## Why mixed-resolution sets arise\n\nThe most common source is [compaction](/docs/h3-compaction-and-uncompaction/):\na uniform resolution-9 cell set gets compacted for storage or transmission,\nand any region where all seven children of a resolution-8 parent are present\ngets replaced by that one parent cell — the compacted set is now, by design,\na mix of resolution 8 and resolution 9 cells (and potentially coarser, if\ncompaction cascades upward). Mixed sets also arise from merging two\nindependently built cell sets (an inclusion built at resolution 7, an\nexclusion built at resolution 9), or from a rollup that stores results at\nwhatever resolution each region's data was originally collected at.\n\n## Why comparison requires normalization\n\nH3 cells are opaque 64-bit indices; a resolution-8 cell and a resolution-9\ncell that geometrically nest inside one another do not share any bit pattern\nthat a naive equality or set-membership check can exploit. Testing whether a\nresolution-9 cell is \"in\" a set that actually contains its resolution-8\nparent requires walking the hierarchy (`cellToParent` upward from the\nresolution-9 cell, or `cellToChildren` downward from the resolution-8 cell)\n— a plain `Set.has()` on raw cell indices will report a false negative every\ntime, because the parent index and the child index are different numbers.\nThe same problem applies to union, intersection, and difference: two mixed\nsets cannot be unioned by simple array concatenation, because a parent cell\nin one set and its own children in the other set are the same geography\nexpressed two different ways, and naively combining them produces silent\ndouble-counting rather than a correct union.\n\n## normalizeToResolution behavior\n\n`normalizeToResolution` resolves every cell in a mixed set to one target\nresolution by expanding or folding as needed:\n\n| | |\n|---|---|\n| 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. |\n| 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. |\n| Cell already at target resolution | Passed through unchanged. |\n\nThe result is a uniform-resolution set that supports ordinary set semantics\nagain: two normalized sets can be unioned, intersected, or diffed with plain\nset operations, and cell-count based area estimates become meaningful because\nevery cell in the result has the same nominal area.\n\n## Parent-child duplicates and double counting\n\nA **parent-child duplicate** is a mixed set that contains both a cell and one\nor more of that cell's descendants (or ancestors) — for example, a\nresolution-7 parent cell and one of its own resolution-9 grandchildren, both\npresent in the same array. Left unnormalized, any per-cell aggregation (a\npopulation count per cell, a spend figure per cell) will count the\noverlapping ground twice: once under the parent's row, once under the\nchild's row, because both rows describe overlapping geography under\ndifferent indices. `hasParentChildDuplicate` detects this condition before it\nreaches an aggregation step, since the fix (normalize to one resolution\nfirst) is cheap but only if applied before, not after, values have already\nbeen summed.\n\n## Logical versus geometric containment\n\n`cellToChildren` and `cellToParent` define a **logical** hierarchy — the H3\nindexing scheme's own parent-child bookkeeping — and it is tempting to treat\nthat hierarchy as geometrically exact: to assume a child cell's boundary lies\nentirely within its parent's boundary. It does not, in general. H3's grid is\nbuilt on an icosahedral projection, and near cell distortion (most visibly\naround pentagons and face-crossing cells, but present at lower magnitude\nthroughout the grid) a child cell's true polygon boundary can extend slightly\noutside its logical parent's polygon boundary. Do not use `cellToParent` /\n`cellToChildren` as a substitute for an actual point-in-polygon or\npolygon-intersection test when geometric exactness matters (for example,\nverifying that a fine-resolution cell physically falls inside a specific\ncoarse-resolution polygon) — the hierarchy is exact as an *indexing*\nrelationship and only approximately exact as a *geometric* one.\n\n## Algorithm\n\n```ts\n\n  normalizeToResolution,\n  hasParentChildDuplicate,\n} from \"@/lib/h3/hierarchy\";\n\n// Detect before aggregating — cheap check, expensive mistake to skip.\nif (hasParentChildDuplicate(mixedCells)) {\n  flagForNormalization(mixedCells);\n}\n\n// Fold coarse cells down / expand fine cells up to one target resolution.\nconst uniform = normalizeToResolution(mixedCells, { resolution: 9 });\n```\n\nThe same conversion with the Python bindings (`h3-py` v4):\n\n```python\n\ndef normalize_to_resolution(cells, resolution: int) -> set[str]:\n    normalized = set()\n    for cell in cells:\n        res = h3.get_resolution(cell)\n        if res == resolution:\n            normalized.add(cell)\n        elif res < resolution:\n            normalized.update(h3.cell_to_children(cell, resolution))\n        else:\n            normalized.add(h3.cell_to_parent(cell, resolution))\n    return normalized\n\ndef has_parent_child_duplicate(cells) -> bool:\n    by_resolution: dict[int, set[str]] = {}\n    for cell in cells:\n        by_resolution.setdefault(h3.get_resolution(cell), set()).add(cell)\n    resolutions = sorted(by_resolution)\n    for i, coarse_res in enumerate(resolutions):\n        for fine_res in resolutions[i + 1:]:\n            for cell in by_resolution[fine_res]:\n                ancestor = h3.cell_to_parent(cell, coarse_res)\n                if ancestor in by_resolution[coarse_res]:\n                    return True\n    return False\n\n# Detect before aggregating — cheap check, expensive mistake to skip.\nif has_parent_child_duplicate(mixed_cells):\n    flag_for_normalization(mixed_cells)\n\n# Fold coarse cells down / expand fine cells up to one target resolution.\nuniform = normalize_to_resolution(mixed_cells, resolution=9)\n```\n\nThe tested reference implementation in this knowledge base is the\nTypeScript in `lib/`; this Python walks each cell's resolution with\n`get_resolution` and expands/folds it with `cell_to_children` /\n`cell_to_parent`, exactly as `normalizeToResolution` does.\n\n> Figure (mixed-resolution): An R7 parent (amber) overlaid with R9 children (green): logical hierarchy is not exact geometric containment.\n\n## Edge cases\n\n[Mixed resolutions](/docs/inclusion-and-exclusion-semantics/) show up\nconstantly at the boundary between an inclusion built at one resolution and\nan exclusion built at another — normalize both to the same resolution before\never computing a set difference between them.\n[Parent-child duplicates](/docs/h3-compaction-and-uncompaction/) are the\nconcrete symptom to test for whenever two cell sets from different pipelines\nare merged; a duplicate check should run as a matter of course on any merged\nset before it is used for area estimation, reporting, or billing.\n\n## Assumptions and limitations\n\nNormalization assumes every cell in the mixed set is a valid H3 index at a\nresolution the target conversion supports; it does not repair cells that\nwere corrupted upstream (wrong resolution recorded in metadata, cell indices\nfrom a different H3 base cell numbering scheme). It also does not restore\ninformation lost by an earlier lossy conversion — normalizing to a uniform\nresolution recovers a comparable set, not the original source geometry that\nproduced the mixed set in the first place."
    },
    {
      "title": "Multipoints",
      "slug": "multipoints",
      "category": "geometries",
      "summary": "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.",
      "source_geometry": [
        "multipoint_audience"
      ],
      "status": "stable",
      "cell_systems": [
        "h3"
      ],
      "platforms": [
        "generic"
      ],
      "edge_cases": [
        "duplicate-observations",
        "sparse-audience-suppression"
      ],
      "related": [
        "point-to-h3",
        "points",
        "privacy-and-minimum-aggregation",
        "conversion-quality-metrics"
      ],
      "badges": [],
      "last_reviewed": "2026-07-22",
      "destination_geometry": [],
      "url": "https://etherdata.ai/blog/geo-interop-kb/docs/multipoints",
      "markdownUrl": "https://etherdata.ai/geo-interop-kb/docs/multipoints.md",
      "markdown": "A multipoint is a collection of point observations that are counted or\naggregated together but do not describe one coherent shape the way a\npolygon does — an hour of bid requests, a week of store visits, a month of\nconversions. Each observation is a member of the [points](/docs/points/)\nfamily individually; what makes this a distinct geometry family is the\naggregate: the questions asked of a multipoint (how many observations per\ncell, how many distinct entities, what's the density) are different from\nthe questions asked of any single point, and answering them correctly\ndepends on getting deduplication and suppression right before the count is\never reported.\n\n| | |\n|---|---|\n| Cardinality | A variable-size set of observations over a window, not one shape |\n| Governed by | The aggregation window and deduplication logic applied, not an external authority |\n| Not a geometry | A device or cookie id — the audience is the aggregate of resolved points |\n| Converts via | Per-observation point-to-cell, then dedup, count, and suppress at the cell level |\n\n## Members\n\n| Member | What each observation represents | Typical volume |\n|---|---|---|\n| Audience observations | A device or cookie's recorded presence | Millions per market per period |\n| Bid requests | An ad-exchange auction event's location field | Very high volume, high duplication risk |\n| Store visits | A resolved visit-to-POI event | Moderate volume, tied to a dwell/visit model |\n| Conversions | A purchase or app-event location | Lower volume, often the measurement target |\n| Event observations | Any other logged occurrence with a coordinate | Varies by source |\n\n## Required metadata\n\n| Field | Why it's required |\n|---|---|\n| CRS | Standard normalization requirement shared with all point-based geometries |\n| Per-observation timestamp | Needed to define the aggregation window and detect staleness within it |\n| Deduplication key | Without one, the same impression or visit can be counted more than once per cell |\n| Entity identifier (where privacy-permitted) | Distinguishes \"10 observations from 1 device\" from \"10 observations from 10 devices\" — a materially different audience signal |\n\n## Common risks\n\n**Duplicate observations** are the dominant risk: the same impression,\nvisit, or conversion logged more than once — from retries, multi-source\ningestion, or an upstream join fanning out — inflates the count or audience\nsize attributed to a cell, and the effect compounds with volume, since\nhigh-volume feeds like bidstream data are also the ones most prone to\nduplicate delivery. A dedup key plus a time window is the standard defense;\nwithout one, \"volume per cell\" silently becomes \"volume per cell times an\nunknown, non-uniform duplication factor,\" which corrupts any comparison\nacross cells with different duplication rates. **Sparse-cell suppression**\nfollows once the multipoint is aggregated: a cell with very few distinct\nobservations risks re-identifying the individuals behind them, and must be\nsuppressed or rolled up to a coarser resolution rather than reported as-is\n— this is the same minimum-aggregation requirement that governs device\npings and trajectories, applied at the aggregate rather than the individual\nlevel. **Entity-vs-observation conflation** is a related but separate\nerror: reporting raw observation counts as if they were unique-entity\ncounts overstates audience size whenever any entity contributes more than\none observation in the window, which is the common case, not the\nexception.\n\n> **Note:** There is no boundary to this family the way there is for a polygon —\n\"the geometry\" of a multipoint is entirely a function of which observations\nwere included, over what time window, after what deduplication. Two\nmultipoints built from the identical underlying event stream but different\nwindows or dedup logic are not comparable cell-for-cell, even though both\nare legitimately \"the same audience.\"\n\n## How it converts to H3\n\nEach observation converts individually with the same `latLngToCell`\nmechanics as [point to H3](/docs/point-to-h3/); what differs at the\nmultipoint level is what happens after conversion — grouping by cell,\ndeduplicating within the window, counting distinct entities rather than raw\nrows, and applying sparse-cell suppression before the aggregate is\nreported. See [privacy and minimum aggregation](/docs/privacy-and-minimum-aggregation/)\nfor the suppression thresholds, and\n[conversion quality metrics](/docs/conversion-quality-metrics/) for how\nduplication and suppression should be surfaced alongside the resulting\nper-cell counts rather than left implicit."
    },
    {
      "title": "Platform Identifiers",
      "slug": "platform-identifiers",
      "category": "geometries",
      "summary": "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.",
      "source_geometry": [
        "platform_geo_id"
      ],
      "status": "stable",
      "cell_systems": [
        "h3"
      ],
      "platforms": [
        "generic"
      ],
      "edge_cases": [
        "platform-native-ids-only",
        "stale-boundaries",
        "asynchronous-boundary-updates"
      ],
      "related": [
        "h3-to-platform-native-geography",
        "administrative-boundaries",
        "geometry-catalogue"
      ],
      "badges": [],
      "last_reviewed": "2026-07-22",
      "destination_geometry": [],
      "url": "https://etherdata.ai/blog/geo-interop-kb/docs/platform-identifiers",
      "markdownUrl": "https://etherdata.ai/geo-interop-kb/docs/platform-identifiers.md",
      "markdown": "This page exists to state, as directly as possible, the rule the rest of\nthis catalogue keeps returning to: **an identifier is not a geometry.**\nEvery other family in this catalogue is a shape, or a rule for constructing\none. This family is a label — a string or code that some system uses to\nrefer to a geographic region — and the region it refers to is not encoded\nin the label at all. It is encoded in a boundary file, at a specific\nvintage, resolved through a specific crosswalk. Two systems can hold the\nidentical identifier and disagree completely about what territory it\ncovers, because the label never changes even when the boundary underneath\nit does.\n\n| | |\n|---|---|\n| Cardinality | A label, not a shape — one id per region per namespace |\n| Governed by | Whichever platform or standard minted the id (FIPS, ISO, DMA, publisher) |\n| Not a geometry | The id itself — the recurring warning this whole page exists to state |\n| Converts via | No direct conversion; H3 resolves outward through a maintained, versioned crosswalk |\n\n## Members\n\n| Member | What it is | Typical namespace |\n|---|---|---|\n| Postal id | A postal-service delivery-area code | ZIP, ZCTA, national postcode |\n| FIPS code | US Census/federal region code | FIPS state/county code |\n| ISO code | International country/subdivision code | ISO 3166-1/2 |\n| DMA id | Nielsen media-market code | DMA code |\n| Publisher id | A media owner's own market label | Publisher-internal |\n| Internal market identifier | A company's own named region | Internal, often undocumented outside the owning team |\n\n## Required metadata\n\n| Field | Why it's required |\n|---|---|\n| Id namespace | The same string can mean different things in different systems — a bare `\"501\"` is meaningless without knowing which namespace produced it |\n| Boundary vintage | The version of the boundary set the id was minted against — this is what actually determines the geometry, not the id itself |\n| 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 |\n\n## Common risks\n\n> **Note:** Resolving a platform identifier to a geometry requires exactly three\nthings: the namespace it belongs to, the vintage of the boundary set it was\nminted against, and a crosswalk that performs that specific resolution.\nSkip any one of the three and the \"geometry\" produced is a guess, not a\nlookup — this is true even when the id itself never changes, because the\nboundary underneath it can move without the id being reissued.\n\n**Namespace ambiguity** is the most common failure: two vendors' \"market\n12\" are different regions, and nothing about the string `\"12\"` signals\nwhich vendor's scheme is in force — ingesting an id without recording its\nsource namespace makes it silently ambiguous the moment a second data\nsource enters the pipeline. **Vintage mismatch** compounds this: even\nwithin one namespace, an id assigned against a 2019 boundary set and\nresolved against a 2024 crosswalk can resolve to a different polygon,\nbecause DMA and postal boundaries specifically are redrawn on cycles far\nshorter than most systems' assumed \"this never changes\" treatment of a\nregion code. **Unmatched ids dropping silently** is the operational\nfailure mode: a join between an id-keyed dataset and a crosswalk table\nthat fails to match — because of a vintage gap, a namespace collision, or a\nsimple typo — commonly drops the unmatched rows rather than raising an\nerror, which understates whatever metric is being computed with no visible\nsignal that anything went wrong. **Platform-native-ids-only constraints**\nare related but distinct: some platforms accept no polygon or coordinate\ninput at all and require everything expressed as one of their own ids,\nmaking a versioned crosswalk to that namespace the only way in — one that\nmust be maintained continuously as the platform revises its boundary\ndefinitions, on its own schedule, invisible to the caller\nunless the platform documents it.\n\n## How it converts to H3\n\nBecause a platform identifier is a reference rather than a shape, there is\nno direct id-to-H3 polyfill — the conversion runs in the opposite\ndirection, from H3 to the platform's id space, through a maintained\ncrosswalk. See\n[H3 to platform-native geography](/docs/h3-to-platform-native-geography/)\nfor how the crosswalk is built and versioned, how unmatched cells are\nreported rather than dropped, and how precision loss is quantified when a\nplatform's id granularity is coarser than the H3 resolution expressed.\nWhere the underlying region is available as an actual polygon, treat it as\nan [administrative boundary](/docs/administrative-boundaries/) instead and\nskip id resolution entirely — the polygon is strictly more information\nthan any id that merely points at it."
    },
    {
      "title": "Platform Target Count Constraints",
      "slug": "platform-target-count-constraints",
      "category": "platforms",
      "summary": "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.",
      "source_geometry": [
        "h3_cell_set"
      ],
      "destination_geometry": [
        "platform_geo_id",
        "point_radius"
      ],
      "exactness": "conservative",
      "status": "stable",
      "cell_systems": [
        "h3"
      ],
      "platforms": [
        "generic"
      ],
      "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"
      ],
      "badges": [
        "Platform-dependent"
      ],
      "last_reviewed": "2026-07-22",
      "url": "https://etherdata.ai/blog/geo-interop-kb/docs/platform-target-count-constraints",
      "markdownUrl": "https://etherdata.ai/geo-interop-kb/docs/platform-target-count-constraints.md",
      "markdown": "platform-dependent\n\n## Purpose\n\nA canonical H3 cell set is an interchange format; no advertising platform\nexecutes on H3 cells directly. Every platform imposes its own constraints\non what a targetable unit can look like, and a conversion that ignores them\nproduces a target list the platform will reject, silently truncate, or\nsilently re-interpret. This page enumerates the recurring constraint\nclasses and the mitigation for each, generically — see\n`data/platforms.yaml` for named platform-class instances of these limits.\n\n## Source geometry and destination geometry\n\nSource geometry is an `h3_cell_set` at whatever resolution the canonical\nrepresentation was built at. Destination geometry is whatever the target\nplatform accepts: `platform_geo_id` (native regions), `point_radius`\n(circles), or occasionally a simplified polygon — never H3 cells\nthemselves, since no mainstream ad platform has native H3 support.\n\n## Exactness class\n\nConservative by design: the fitting process in this page trades resolution\nand target-count for platform executability, and every mitigation below\neither coarsens the footprint or drops a request outright rather than\nsilently exceeding a hard platform limit.\n\n## The constraint classes\n\n| | |\n|---|---|\n| 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. |\n| 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. |\n| Radius increments | Platforms round a requested radius to a fixed increment (e.g. nearest 100 m), changing executed coverage from the requested value. |\n| 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. |\n| 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. |\n| 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. |\n| Platform-native IDs only | Some platforms accept only their own named geography IDs, requiring a maintained crosswalk from H3 with an accepted precision loss. |\n| Coordinate rounding | Platforms may round submitted circle-center coordinates on ingest, shifting the executed center by up to the rounding's implied distance. |\n| Undocumented deduplication | Some platforms silently merge or drop entries that overlap beyond an undocumented threshold, changing the executed count without warning. |\n| 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. |\n| 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. |\n| 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. |\n\n## Containment rule and boundary behavior\n\nFitting an H3 cell set to a platform's constraints is, by construction,\nconservative-toward-executability, not conservative-toward-coverage: the\nprocess may coarsen resolution (increasing overreach) or drop unexecutable\nelements (reducing coverage) to satisfy a hard limit. Both outcomes must be\nreported — the fitted target list is not the same geography as the\ncanonical H3 input, and the gap has a direction (coarsening overreaches;\ndropping underreaches) attributable to the constraint that forced it.\n\n## Resolution behavior\n\nResolution is the primary lever for fitting target-count limits: compacting\nadjacent same-value cells (`compact`) and, where insufficient, coarsening\nto a higher parent resolution reduces cell count at the cost of boundary\nprecision. For minimum-radius floors, coarsening is again the lever — a\ncoarser cell's circumscribed radius is larger and more likely to clear the\nfloor — while for maximum-radius ceilings the opposite applies: a\ncircumscribed circle exceeding the ceiling must split into smaller circles.\n\n## Units and CRS\n\nEPSG:4326 for all geometry; radii in meters unless a platform's API\ndocuments feet or another unit, in which case the conversion must convert\nexplicitly and record the platform's native unit in the `ConversionRecord`.\n\n## Algorithm\n\n```ts\n\nfunction fitToPlatform(cells: string[], platform: {\n  minRadius: number | null;\n  maxRadius: number | null;\n  maxTargets: number | null;\n}) {\n  let working = compact(cells);\n\n  // Coarsen resolution stepwise until under maxTargets.\n  while (platform.maxTargets && working.length > platform.maxTargets) {\n    working = compact(coarsenOneLevel(working));\n  }\n\n  // Fit each remaining cell's circumscribed circle to min/max radius.\n  const circles = working.map((cell) => {\n    const { radiusMeters, center } = circumscribedCircle(cell);\n    const clamped = clampRadius(radiusMeters, platform.minRadius, platform.maxRadius);\n    return { center, radiusMeters: clamped };\n  });\n\n  return circles;\n}\n```\n\n## Parameters\n\nThe target platform's declared `minRadius`, `maxRadius`, `radiusIncrements`,\n`maxTargets`, `polygonSupport`, `exclusionSupport`, `adminIdSupport`, and\n`nativeCellSupport` — all sourced from a platform capability record such as\n`data/platforms.yaml`, never assumed or hardcoded per campaign.\n\n## Outputs\n\nA platform-executable target list (circles, native IDs, or a simplified\npolygon set) alongside a record of every coarsening step, dropped\nexclusion, and cell merged past its original boundary — the inputs needed\nto compute `overreach_ratio` and `coverage_ratio` against the canonical set.\n\n## Quality metrics\n\n`overreach_ratio` and `coverage_ratio` against the pre-fitting canonical\ncell set, per the `platform_limit_optimized` profile\n([conversion-profiles](/docs/conversion-profiles/)), whose stated guarantee\nis that the result respects `maxTargets` and `minRadius` for the named\nplatform and whose stated tradeoff is that coarsening enlarges the\neffective footprint.\n\n## Edge cases\n\n`minimum-radius` and `radius-increments` interact: a cell coarsened just\nenough to clear the minimum floor can still shift meaningfully once the\nplatform rounds to its nearest increment, so the final check must run\nagainst the platform-quantized radius, not the pre-rounding one.\n`platform-native-ids-only` and `unsupported-exclusions` both require a\nfallback decided in advance — pre-subtracting an exclusion in cell space\nbefore crosswalking to native IDs is the standard mitigation, flagged as\nreported-vs-executed divergence regardless.\n\n## Assumptions and limitations\n\nThis page documents constraint classes generically; exact numeric limits\nmust be sourced from the platform's current API documentation via a\ncapability record, never from memory or this page — platform limits change\nwithout notice, and a stale assumption here is a `stale-boundaries`-class\nfailure applied to platform capabilities rather than geographic ones.\n\n## Illustration — the minimum-radius floor\n\n> 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."
    },
    {
      "title": "Point To H3",
      "slug": "point-to-h3",
      "category": "source-to-h3",
      "summary": "Point-to-cell containment is exact given a coordinate; the real subject of this page is how uncertain that coordinate usually is.",
      "source_geometry": [
        "poi",
        "address",
        "device_ping"
      ],
      "destination_geometry": [
        "h3_cell_set"
      ],
      "exactness": "exact",
      "status": "stable",
      "cell_systems": [
        "h3"
      ],
      "platforms": [
        "generic"
      ],
      "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"
      ],
      "badges": [
        "Exact"
      ],
      "last_reviewed": "2026-07-22",
      "url": "https://etherdata.ai/blog/geo-interop-kb/docs/point-to-h3",
      "markdownUrl": "https://etherdata.ai/geo-interop-kb/docs/point-to-h3.md",
      "markdown": "exact\n\n## Purpose\n\nGiven a latitude/longitude pair, finding the H3 cell that contains it is a\ndeterministic, exact geometric operation — the H3 grid partitions the sphere,\nso every point lies in exactly one cell at a given resolution (ignoring the\nmeasure-zero case of a point exactly on a cell boundary). What is not exact,\nand what this page is actually about, is the coordinate itself: POI\nlocations, geocoded addresses, and device pings all carry uncertainty that\nthe point-to-cell step inherits silently unless it is tracked explicitly.\n\n## Source geometry and destination geometry\n\nSource geometry is one of `poi` (a point of interest with a claimed\nlocation), `address` (a mailing or street address prior to geocoding), or\n`device_ping` (a single observed lat/lng with a timestamp, typically from a\nmobile SDK or bid-stream signal). Destination geometry is a single H3 cell,\nor an `h3_cell_set` when a set of points is aggregated.\n\n## Exactness class\n\nPoint-to-cell containment is **exact**: `latLngToCell` returns the one cell\nwhose boundary contains the given point, with no approximation in the\ncontainment test itself. This page's badge describes the *conversion step*,\nnot the *input* — a device ping accurate to only 500 meters is still mapped\nto exactly one cell, but that cell may not be the cell the true device\nlocation would have produced.\n\n## Containment rule and boundary behavior\n\nA point belongs to the unique cell whose polygon boundary contains it under\nH3's standard point-in-polygon test. Points falling exactly on a shared edge\nor vertex between cells are resolved by H3's internal tie-breaking rather\nthan by any caller-visible rule; do not rely on which side a boundary point\nresolves to being stable across H3 library versions. This matters in\npractice for `device_ping` data snapped to a grid (see rounded coordinates,\nbelow) where a large share of points can land precisely on cell boundaries\nrather than being uniformly distributed within cells.\n\n## Resolution behavior\n\nResolution does not change whether the operation is exact — it changes how\nmuch a fixed amount of coordinate uncertainty matters. At res 6 (average\ncell edge length near 3.2 km), a 50-meter GPS error essentially never moves\na point to a different cell. At res 10 (edge length near 65 m), the same\n50-meter error frequently does. The right rule of thumb: choose a resolution\nwhose cell diameter is large relative to the stated accuracy radius of the\nsource, or explicitly report the probability the true point falls in a\nneighboring cell (via `gridDisk` around the assigned cell).\n\n## Units and CRS\n\nInput must be EPSG:4326 decimal degrees, latitude first. Accuracy radius,\nwhere available, is reported in meters and should be treated as a 1-sigma\nor CEP50 radius depending on the source's own documentation — mobile SDKs\nand geocoders rarely agree on which, and mixing them without checking\nproduces confidence estimates that are wrong by a factor of two or more.\n\n## Algorithm\n\n```ts\n\nconst cell = latLngToCell(lat, lng, resolution);\n\n// Report neighbor cells the true point could plausibly occupy,\n// given a stated accuracy radius larger than the cell's own scale.\nconst candidateNeighbors = gridDisk(cell, 1);\n```\n\nThe same conversion with the Python bindings (`h3-py` v4):\n\n```python\n\ncell = h3.latlng_to_cell(lat, lng, res)\n\n# Report neighbor cells the true point could plausibly occupy,\n# given a stated accuracy radius larger than the cell's own scale.\ncandidate_neighbors = h3.grid_disk(cell, 1)\n\n# Cap the usable resolution to the coordinate's own precision: if lat/lng\n# were truncated (see rounded-coordinates below), h3.get_resolution(cell)\n# should not be trusted as more precise than the source data actually is.\nassigned_resolution = h3.get_resolution(cell)\n```\n\nThe tested reference implementation for this conversion is the TypeScript\nin `lib/` (via `h3-js`); the Python above calls the equivalent `h3-py`\nfunctions directly, since point-to-cell containment has no library-specific\nlogic to reproduce.\n\n## Parameters\n\nResolution, and — where the source provides it — an accuracy radius in\nmeters used only for downstream uncertainty reporting, never to alter the\ncontainment result itself.\n\n## Outputs\n\n`cell_id` at the stated resolution, the original coordinate pair for\naudit, and, when available, the source's own accuracy radius and\nobservation timestamp carried through unchanged.\n\n## Quality metrics\n\nPoint conversions do not have a `coverage_ratio` in the polygon sense.\nInstead track: the fraction of points whose accuracy radius exceeds the\nchosen cell's edge length (a proxy for how often the \"true\" cell may differ\nfrom the assigned one), and the duplicate rate — the fraction of\nobservations sharing an identical coordinate pair, timestamp, and source id.\n\n## Edge cases\n\nAxis-order reversal is the most common silent failure: many formats list\nlongitude first, and swapping lat/lng produces a coordinate that is often\nstill a valid point on Earth — frequently landing in the ocean or another\ncontinent — with no error thrown. Always validate that latitude falls in\nnegative-90 to 90 and longitude in negative-180 to 180, and where both are\nin range, cross-check against an expected bounding region rather than\ntrusting field order. Rounded coordinates — device pings or POI feeds\ntruncated to 2-3 decimal places for privacy or storage reasons — can\nintroduce error of 100 meters to over 1 km depending on latitude, which is\nroutinely larger than a res-9 or res-10 cell; treat any coordinate with\nsuspiciously round decimal digits as lower-resolution input and cap the H3\nresolution used for it accordingly, rather than polyfilling it as if it\nwere survey-grade. Geocoding uncertainty applies to `address` inputs before\nthey become points at all: rooftop, parcel-centroid, street-segment\ninterpolation, and ZIP-centroid geocodes carry radically different implied\naccuracy (meters versus kilometers), and the geocode tier should be\nrecorded alongside the resulting cell. Duplicate observations — the same\ndevice or POI reported multiple times at the same or near-identical\ncoordinate within a short window — inflate density counts if not\ndeduplicated by source id and timestamp window before aggregation into a\ncell-level count.\n\n## Assumptions and limitations\n\nThis conversion assumes the input coordinate is the caller's best available\nestimate of a real-world location; it does not attempt to detect or correct\nfor measurement error, only to propagate accuracy metadata alongside the\nassigned cell where it exists. Stale POIs (a business location that closed\nor moved but remains in a feed) are a data-freshness problem, not a\ngeometric one — no adjustment of resolution or containment rule corrects for\na POI at the wrong address; that requires refreshing the source rather than\ntuning this conversion.\n\n## Illustration — the point is exact, the location is not\n\n> 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)."
    },
    {
      "title": "Point-Radius Geometries",
      "slug": "point-radius-geometries",
      "category": "geometries",
      "summary": "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.",
      "source_geometry": [
        "point_radius",
        "device_ping"
      ],
      "status": "stable",
      "cell_systems": [
        "h3"
      ],
      "platforms": [
        "generic"
      ],
      "edge_cases": [
        "minimum-radius",
        "radius-increments"
      ],
      "related": [
        "point-radius-to-h3",
        "h3-to-inscribed-circle",
        "h3-to-circumscribed-circle",
        "points",
        "arbitrary-polygons"
      ],
      "badges": [],
      "last_reviewed": "2026-07-22",
      "destination_geometry": [],
      "url": "https://etherdata.ai/blog/geo-interop-kb/docs/point-radius-geometries",
      "markdownUrl": "https://etherdata.ai/geo-interop-kb/docs/point-radius-geometries.md",
      "markdown": "A point-radius geometry is a center coordinate plus a distance: not a\npolygon at rest, but a rule for constructing a disk on demand. It is the\nnative execution unit of most demand-side platforms and proximity-targeting\nproducts precisely because it is cheap to specify and cheap to execute —\n\"3 km around this point\" is one comparison per candidate location, not a\npoint-in-polygon test against an arbitrary boundary. That efficiency is\nalso the family's limitation: a circle is rarely the true shape of the\ncatchment it's standing in for, and the radius itself hides two decisions\n(geodesic vs. planar, and which unit) that change the executed area even\nwhen the stated number never changes.\n\n| | |\n|---|---|\n| Cardinality | One disk per center-plus-radius pair, constructed on demand, not stored |\n| Governed by | The platform or caller that defines the radius — no external authority |\n| Not a geometry | The radius number alone, before a buffer method (geodesic or planar) is chosen |\n| Converts via | Geodesic buffer to a disk polygon, then polyfill |\n\n## Members\n\n| Member | What it represents | Typical source |\n|---|---|---|\n| Proximity targeting | \"Within N km/mi of this point\" ad or promo target | Platform targeting UI |\n| Store radius | A simplified stand-in for a store's real trade area | Manually chosen or platform default |\n| Device-accuracy disk | A device ping's positional uncertainty, expressed as a radius | GPS/network accuracy metadata |\n| Service radius | The area a business claims to serve | Manually declared |\n| Platform radius target | A DSP/ad-server's own point+radius execution primitive | Platform API parameter |\n\n## Required metadata\n\n| Field | Why it's required |\n|---|---|\n| Radius units | Meters, feet, and miles are all in circulation; an unconverted unit produces an order-of-magnitude error |\n| Geodesic vs. planar | Determines whether the radius is measured as a great-circle distance or in a locally flat projection |\n| Center coordinate CRS | Almost always EPSG:4326, but must be confirmed for ingested platform exports |\n| Platform-declared minimum radius | Needed to know, before execution, whether the requested radius will be silently clamped up |\n\n## Common risks\n\n**Geodesic vs. planar divergence**: a geodesic buffer measures the radius\nas a true great-circle distance from the center; a planar buffer applies it\nin a locally flat projection that is only accurate near that projection's\nreference latitude. At mid-latitudes and radii under 10 km the gap is\nusually under 1%, but it grows with both latitude and radius — a planar\nbuffer at high latitude or over several kilometers can misstate coverage\narea by several percent, and the two methods should never be mixed within\none campaign's targets. **Platform minimum-radius floors**: many platforms\nrefuse radii below a threshold (500 m to 1 km is common) and silently clamp\na smaller request up to the floor rather than rejecting it — a\n200-meter catchment intended for conservative targeting can execute as a\n1 km disk instead, and the requested-vs-executed radius should always be\ndiffed rather than assumed equal. **Radius increments**: some platforms\nround the requested radius to a fixed step, changing the executed area in\neither direction depending on rounding rules. **Travel-time confusion**: a\n\"10-minute drive\" catchment is not a disk, and picking a radius that looks\nvisually similar on a map discards the road-network shape that made the\ncatchment meaningful in the first place — that case belongs to\n[arbitrary polygons](/docs/arbitrary-polygons/), not this family.\n\n> **Note:** Until it is buffered, a point-radius target has no geometry at all — just a\nnumber. Two systems agreeing on the same center and the same stated radius\ncan still execute two different disks if one buffers geodesically and the\nother planar, or if one platform's minimum-radius floor silently overrides\nthe smaller of the two requests.\n\n## How it converts to H3\n\nPoint-radius targets are buffered into a geodesic (or, when matching\nplatform behavior, planar) disk and then polyfilled under the same\ncontainment modes used for arbitrary polygons — see\n[point-radius to H3](/docs/point-radius-to-h3/) for the buffer-then-fill\nalgorithm in both TypeScript and `h3-py`. The reverse direction — expressing\nan H3 cell back out as a platform-native point-radius target — is covered\non [H3 to inscribed circle](/docs/h3-to-inscribed-circle/) (the largest\ndisk guaranteed to stay inside the cell) and\n[H3 to circumscribed circle](/docs/h3-to-circumscribed-circle/) (the\nsmallest disk guaranteed to cover it), which is where platform minimum-radius\nfloors most often bite."
    },
    {
      "title": "Point-Radius To H3",
      "slug": "point-radius-to-h3",
      "category": "source-to-h3",
      "summary": "Buffering a point into a geodesic disk before polyfilling, and why the buffer method matters as much as the containment rule that follows it.",
      "source_geometry": [
        "point_radius",
        "device_ping"
      ],
      "destination_geometry": [
        "h3_cell_set"
      ],
      "exactness": "approximate",
      "status": "stable",
      "cell_systems": [
        "h3"
      ],
      "platforms": [
        "generic"
      ],
      "edge_cases": [
        "minimum-radius"
      ],
      "related": [
        "arbitrary-polygon-to-h3",
        "h3-to-inscribed-circle",
        "point-to-h3",
        "h3-to-equal-area-circle"
      ],
      "badges": [
        "Approximate"
      ],
      "last_reviewed": "2026-07-22",
      "url": "https://etherdata.ai/blog/geo-interop-kb/docs/point-radius-to-h3",
      "markdownUrl": "https://etherdata.ai/geo-interop-kb/docs/point-radius-to-h3.md",
      "markdown": "approximate\n\n## Purpose\n\nPoint-radius targets — \"3 miles around this store,\" a device ping treated as\na small catchment, a platform-defined default radius around a POI — are not\npolygons at the source, but they become one as the first step of this\nconversion. Everything after buffering is the same containment machinery as\n[arbitrary polygon to H3](/docs/arbitrary-polygon-to-h3/); this page is about\ngetting the buffer itself right, since a wrong buffer poisons every mode\ndownstream of it.\n\n## Source geometry and destination geometry\n\nSource geometry is `point_radius` (a center coordinate plus a radius,\nhowever specified) or `device_ping` when a ping is being expanded into an\nuncertainty disk rather than treated as an exact point. Destination\ngeometry is an `h3_cell_set`, or a weighted crosswalk if the disk is later\nintersected against multiple regions.\n\n## Exactness class\n\nApproximate at two layered points: the disk itself is an approximation of\nwhatever the \"true\" catchment shape is (real trade areas are rarely\ncircular), and the polyfill of that disk inherits the approximation of\nwhichever containment mode is chosen.\n\n## Containment rule and boundary behavior\n\nThe buffer step and the containment step are independent decisions:\n\n1. **Buffer**: construct a geodesic disk of the stated radius around the\n   center point.\n2. **Containment**: polyfill that disk under one of the four modes —\n   `center`, `full`, `intersect`, or `threshold` — exactly as documented on\n   the [arbitrary polygon](/docs/arbitrary-polygon-to-h3/) page.\n\nThe buffer itself has a boundary-behavior choice that predates polyfilling:\ngeodesic versus planar radius. A **geodesic** buffer measures the radius as\na great-circle distance from the center, correctly accounting for Earth's\ncurvature; a **planar** buffer applies the radius in a locally flat\nprojection and is only accurate near the projection's reference latitude.\nAt mid-latitudes over radii under 10 km the discrepancy is usually under 1%,\nbut it grows with both latitude and radius, and a planar buffer applied at\nhigh latitude or over a multi-kilometer radius can miscalculate coverage\narea by several percent — always default to geodesic unless a platform\nexplicitly executes planar circles, in which case the planar buffer should\nbe used to match what will actually be delivered, not what is geometrically\ncorrect.\n\n> Figure (point-radius): A 500 m disk buffered from a point, filled at R10 (intersect): 67 cells.\n\n## Resolution behavior\n\nAs with any polyfilled disk, higher resolution tightens the gap between the\npolyfilled cell set and the true disk area for whichever containment mode is\nused. Small radii interact badly with coarse resolutions: a 500-meter radius\ndisk polyfilled at res 6 (edge length ~3.2 km) may resolve to a single cell\nunder `center` regardless of where within that cell the true center falls,\nwhich defeats the purpose of specifying a radius at all — resolution should\nbe chosen so the disk diameter spans at least several cells.\n\n## Units and CRS\n\nCenter coordinate in EPSG:4326; radius in meters, converted from any source\nunit (miles, feet, drive-time-derived meters) before buffering. Geodesic\nbuffering computes distance as haversine (or a more precise geodesic\nformula for large radii) rather than planar Euclidean distance in degrees,\nwhich is not a distance unit at all and varies with latitude.\n\n## Algorithm\n\n```ts\n\n// Geodesic disk buffer, then polyfill under the chosen containment mode\nfunction pointRadiusToH3(center, radiusMeters, resolution, mode) {\n  const disk = geodesicBuffer(center, radiusMeters); // polygon approximation\n  return polygonToH3(disk, { resolution, mode });\n}\n\nconst cells = pointRadiusToH3(storeLocation, 4828, 9, \"intersect\"); // 3 mi\n```\n\nThe same conversion with the Python bindings (`h3-py` v4):\n\n```python\n\nfrom shapely.geometry import Point, Polygon\nfrom shapely.ops import transform\n\ndef geodesic_disk(center_lat, center_lng, radius_m, vertices=64):\n    # Geodesic buffer: use an azimuthal-equidistant projection centered on\n    # the point so a planar buffer of radius_m is exact at that center,\n    # then reproject the ring back to lat/lng. A naive buffer in raw\n    # lat/lng degrees is a planar (not geodesic) approximation and drifts\n    # with latitude and radius, per the note above.\n    aeqd = pyproj.CRS.from_proj4(\n        f\"+proj=aeqd +lat_0={center_lat} +lon_0={center_lng} +units=m\"\n    )\n    to_aeqd = pyproj.Transformer.from_crs(\"EPSG:4326\", aeqd, always_xy=True).transform\n    to_wgs84 = pyproj.Transformer.from_crs(aeqd, \"EPSG:4326\", always_xy=True).transform\n    disk_m = Point(0, 0).buffer(radius_m, quad_segs=vertices // 4)\n    disk_deg = transform(to_wgs84, disk_m)  # back to (lng, lat) in EPSG:4326\n    return [(lat, lng) for lng, lat in disk_deg.exterior.coords]  # h3-py wants (lat, lng)\n\ndef point_radius_to_h3(center_lat, center_lng, radius_m, res):\n    ring = geodesic_disk(center_lat, center_lng, radius_m)\n    poly = h3.LatLngPoly(ring)\n    return h3.polygon_to_cells(poly, res)  # center containment only, see note\n\ncells = point_radius_to_h3(store_lat, store_lng, 4828, 9)  # ~3 mi\n```\n\n`h3.polygon_to_cells` is center-containment only; reproducing `full`,\n`intersect`, or `threshold` on the disk requires the same per-cell\n`shapely` area classification shown on the\n[arbitrary polygon](/docs/arbitrary-polygon-to-h3/) page. The tested\nreference implementation for this conversion is the TypeScript in `lib/`.\n\n## Parameters\n\nCenter coordinate, radius (meters, after unit conversion), buffer method\n(`geodesic` or `planar`), containment mode, resolution, and vertex count\nused to approximate the disk polygon (more vertices reduce polygonal\napproximation error at the cost of polyfill time).\n\n## Outputs\n\nAn `h3_cell_set` at the stated resolution and mode, or a weighted crosswalk\nof `(cell_id, disk_id, intersection_area, cell_coverage_fraction)` rows when\nthe disk is being intersected against multiple downstream regions rather\nthan consumed on its own.\n\n## Quality metrics\n\n`coverage_ratio`, `overreach_ratio`, and `jaccard` computed against the\nbuffered disk polygon (not the polyfilled cell set against itself) — this\nisolates polyfill error from buffer error. Report the two error sources\nseparately: disk-polygon-vertex-count error (how well the disk approximates\na true circle) and containment-mode error (how well the cell set approximates\nthe disk).\n\n## Edge cases\n\nTravel-time catchments are frequently confused with point-radius: a\n\"10-minute drive\" catchment is not a disk at all, and forcing it through this\nconversion by picking an equivalent-looking radius silently discards the\nroad-network shape that made the catchment meaningful — that case belongs to\na network-distance corridor, not a geodesic buffer. Platform-defined radii\n(a DSP that only accepts point+radius targeting and enforces its own minimum,\ne.g. 1 km) mean the caller's intended radius and the executed radius can\ndiffer; always record both the requested and the platform-clamped radius.\nMinimum-radius constraints are the sharpest edge case: many platforms refuse\nradii below a floor (500 m to 1 km is common), silently clamping a smaller\nrequest up to the floor rather than erroring — a store-specific 200-meter\ncatchment intended for `full`-mode conservatism can be executed as a\n1-kilometer disk instead, changing `overreach_ratio` from near zero to\nsubstantial. Always diff the requested radius against the platform's\ndocumented minimum before treating the executed geometry as a faithful\nrepresentation of the request, and see [inscribed-circle](/docs/h3-to-inscribed-circle/)\nfor the reverse direction of this problem, where an H3 cell is expressed as\na platform-native point-radius target.\n\n## Assumptions and limitations\n\nThis conversion assumes the radius is a genuine Euclidean (geodesic)\ndistance from a single center point; it is the wrong tool for irregular or\ndirectional catchments, and should not be dressed up as one by picking a\nradius that merely looks visually similar on a map. It also assumes the\nbuffer step runs before any containment decision — polyfilling a raw point\nwith a \"radius\" parameter passed into a polygon mode without an actual\nbuffered polygon is not this conversion and produces undefined results."
    },
    {
      "title": "Points",
      "slug": "points",
      "category": "geometries",
      "summary": "A single coordinate that references a place rather than describing an extent — POIs, addresses, devices, and the uncertainty each one silently carries.",
      "source_geometry": [
        "poi",
        "address",
        "device_ping"
      ],
      "status": "stable",
      "cell_systems": [
        "h3"
      ],
      "platforms": [
        "generic"
      ],
      "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"
      ],
      "badges": [],
      "last_reviewed": "2026-07-22",
      "destination_geometry": [],
      "url": "https://etherdata.ai/blog/geo-interop-kb/docs/points",
      "markdownUrl": "https://etherdata.ai/geo-interop-kb/docs/points.md",
      "markdown": "A point is a single `(lat, lng)` coordinate that stands in for a place, an\nevent, or a moment, but never describes an extent of its own. That\ndistinction matters more than it looks: a point is frequently treated as if\nit has no error — as if a store really does sit at exactly\n`40.7128, -74.0060` — when every member of this family carries some\npositional uncertainty that a downstream conversion needs to be told about\nexplicitly rather than allowed to assume away.\n\n| | |\n|---|---|\n| Cardinality | One coordinate per observation — no extent |\n| Governed by | Whatever produced the coordinate: a GPS chip, a geocoder, a manual placement |\n| Not a geometry | An address string, before a geocoder resolves it to a coordinate |\n| Converts via | Direct latLngToCell at the chosen resolution |\n\n## Members\n\n| Member | What the point represents | Typical uncertainty source |\n|---|---|---|\n| POI / store point | A place's canonical location | Rooftop vs. building-centroid placement |\n| Address | A postal address, pre-geocoding | Geocoder match confidence |\n| Venue entrance | A single entry point on a larger footprint | Which entrance was digitized, and when |\n| Device ping | A device's reported coordinate | GPS/network accuracy radius |\n| Impression | An ad-serving bid or render event's location field | Bidstream truncation, IP fallback |\n| Conversion | A purchase or app event's location field | Attribution-window location capture method |\n| Transaction | A point-of-sale record's location | Store location, not customer location |\n| Sensor reading | A fixed or mobile sensor's coordinate | Installation survey accuracy |\n| Event | A single logged occurrence | Whatever produced the coordinate upstream |\n\n## Required metadata\n\n| Field | Why it's required |\n|---|---|\n| CRS | Nearly always EPSG:4326, but never assume — verify, especially for ingested vendor feeds |\n| Source | Geocoded, device-reported, or manually placed — each carries a different error profile |\n| Timestamp | A point is a snapshot; without a timestamp, staleness can't be assessed |\n| Accuracy radius (device pings) | Converts a bare coordinate into an honest uncertainty disk instead of a false-precision point |\n| Consent state (device pings) | Determines the resolution the point may legally or contractually be used at |\n| Geocoder + match confidence (addresses) | A low-confidence geocode should not be treated with the same trust as a rooftop match |\n\n> **Note:** An address is text. It becomes a member of this family only after a\ngeocoder resolves it to a coordinate — and that resolution is itself an\napproximation, ranging from a precise rooftop match to a ZIP-centroid\nfallback with no street-level information at all. Carrying the match\nconfidence forward is what keeps a bad geocode from being consumed as if it\nwere as trustworthy as a surveyed POI.\n\n## Common risks\n\n**Geocoding uncertainty** is the largest source of error for addresses and\nlow-quality POI feeds: a rooftop match and a street-centroid or ZIP-centroid\nfallback can differ by tens to thousands of meters, and the geocoder's\nconfidence score is the only signal that distinguishes them. **Axis-order\nreversal** — a coordinate supplied as `[lat, lng]` where `[lng, lat]` is\nexpected, or vice versa — silently places a point in the wrong hemisphere\nwhen both values happen to fall in valid range; range-checking and\nswap-detection should reject ambiguous cases rather than guess. **Rounding\nand truncation** (bidstream coordinates rounded to 2–3 decimal places) snap\npoints to a coarse grid that biases which cell they land in, especially at\nhigh H3 resolutions where the rounding grid is coarser than the cell\nitself. **IP-derived location** is coarse and centroid-biased by\nconstruction — it is not physical presence and must be labeled with the\ncorrect matching semantic, not treated as a device fix (see\n[advertising-geographic matching semantics](/docs/advertising-geographic-matching-semantics/)).\n**Staleness** affects POIs specifically: a store point surveyed years ago\nmay no longer reflect a relocated or closed location. **Duplicates** — the\nsame event logged more than once — inflate counts once points are\naggregated; see [multipoints](/docs/multipoints/) for the aggregate case.\n\n## How it converts to H3\n\nA single point converts to H3 with `latLngToCell` at the chosen resolution\n— conceptually the simplest conversion in this knowledge base, and for that\nreason the one where skipped metadata (accuracy, consent, geocoder\nconfidence) does the most silent damage. See\n[point to H3](/docs/point-to-h3/) for the full treatment, including how\naccuracy radius should be handled when a point is really a device fix\nrather than a surveyed location, and\n[point-radius geometries](/docs/point-radius-geometries/) for when a point\nis deliberately expanded into a disk before conversion."
    },
    {
      "title": "Privacy And Minimum Aggregation",
      "slug": "privacy-and-minimum-aggregation",
      "category": "privacy",
      "summary": "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.",
      "source_geometry": [
        "device_ping",
        "trajectory",
        "h3_cell_set"
      ],
      "destination_geometry": [
        "h3_cell_set",
        "multipoint_audience"
      ],
      "status": "stable",
      "cell_systems": [
        "h3"
      ],
      "platforms": [
        "generic"
      ],
      "edge_cases": [
        "sparse-audience-suppression",
        "consent-precision",
        "device-trajectory-exposure"
      ],
      "related": [
        "advertising-geographic-matching-semantics",
        "resolution-selection",
        "conversion-profiles"
      ],
      "badges": [
        "Privacy-sensitive"
      ],
      "last_reviewed": "2026-07-22",
      "url": "https://etherdata.ai/blog/geo-interop-kb/docs/privacy-and-minimum-aggregation",
      "markdownUrl": "https://etherdata.ai/geo-interop-kb/docs/privacy-and-minimum-aggregation.md",
      "markdown": "privacy-safe\n\n## Why aggregation, not geometry, is the privacy boundary\n\nA polygon or an H3 cell is not private or unsafe by itself; the risk is a\nfunction of how many distinct people or devices are represented inside it.\nA precisely drawn, geometrically exact 50 m cell around a single-family home\nis a privacy failure at any exactness level, while a coarse, approximate 5 km\ncell over a stadium during an event is not. Every conversion in this\nknowledge base that terminates in an audience count or a device set must\ntherefore carry a minimum aggregation rule alongside its containment rule —\nthe two are independent constraints and a page that documents one without\nthe other is incomplete.\n\n## Small-cell re-identification\n\nA cell reporting an audience count of one to a small number of digits allows\nan observer with any side information (a home address, a workplace, a\nschedule) to re-identify the specific person or household the count refers\nto. This risk exists at every resolution: a coarse cell with a sparse\npopulation (a rural R5 cell with three households) is exactly as exposed as\na fine cell in a dense city, because the risk is driven by population count\ninside the cell, not cell area.\n\n> **Note:** Do not use H3 resolution as a stand-in for privacy safety. A resolution\nfloor without a population/audience floor still permits small-cell exposure\nin sparse geographies; a population floor without a resolution floor still\npermits gerrymandered slivers drawn to isolate one household. Both\nconstraints must be enforced together.\n\n## k-anonymity thresholds and sparse-audience suppression\n\nThe standard mitigation is a k-anonymity threshold: no reported cell may\nrepresent fewer than k distinct people or devices, for a k set by policy\n(commonly 5–50 depending on jurisdiction and data sensitivity). Enforcement\nhas two mechanisms, and most production systems use both:\n\n- **Suppression** — drop cells below the threshold from the output\n  entirely, accepting a coverage gap.\n- **Roll-up** — merge a below-threshold cell with its parent (coarser\n  resolution) or with adjacent cells until the merged population clears k,\n  accepting a resolution loss for that region only.\n\n`sparse-audience-suppression` is the general term for both mechanisms.\nSuppression is preferred when a coverage gap is disclosable and acceptable;\nroll-up is preferred when a caller needs a value for every requested cell\nand can tolerate uneven resolution across the response.\n\n## Differential privacy and temporal leakage\n\nThreshold suppression alone is defeated by repeated queries: an attacker who\nqueries the same region across multiple time windows, or across overlapping\ncell sets, can reconstruct a below-threshold value by subtraction (query\nregion A, query region A plus one household, take the difference).\nDifferential privacy mitigates this by adding calibrated noise to every\nreleased count, bounding the information any single query — or any\ncombination of queries — can reveal about one individual, regardless of how\nmany times the same underlying population is queried. Threshold suppression\nwithout noise is a necessary but not sufficient control; a system exposed to\nrepeated or overlapping queries needs differential privacy or an equivalent\nquery-budget mechanism on top of it.\n\nTemporal leakage is the time-axis version of the same problem: a cell that\nclears k-anonymity when aggregated over a month can fall below k when\nsliced to a single hour of a single day. The **minimum aggregation window**\nis the smallest time bucket at which the population/audience floor still\nholds, and it must be enforced as a floor on query granularity, not just on\nspatial resolution.\n\n## Household-level targeting risk and device trajectory exposure\n\nHome-location and household-graph targeting (see\n[advertising-geographic-matching-semantics](/docs/advertising-geographic-matching-semantics/))\nconcentrates risk because the geometry — a single parcel or a fine H3 cell —\nis drawn specifically to isolate one household by construction, not as a\nside effect of a coarse aggregation. This is a distinct risk class from\ncell-count suppression and requires a minimum-parcel or minimum-radius floor\nindependent of any population threshold.\n\nDevice trajectories carry a related but separate risk: an ordered sequence\nof high-resolution location points is frequently uniquely identifying even\nwhen no single point in the sequence would be, because the sequence itself\n(home, then a specific workplace, then a specific gym, in that order) is a\nfingerprint. The mitigation is not point-level suppression but sequence-level\ntreatment — dropping temporal order, aggregating to origin-destination pairs\nwithout intermediate points, or coarsening both the spatial and temporal\ngrain of the trajectory before it is stored or joined to any other dataset.\n\n## Resolution degradation and regional restrictions\n\nConsent state and jurisdiction both function as external inputs to the\naggregation floor, not as geometry inputs: a user who has not granted\nprecise-location consent must have their location coarsened to the\nplatform's declared reduced-precision tier before any cell assignment, and a\nregion under a stricter regional privacy regime may carry a higher k or a\ncoarser minimum resolution than the platform's global default. Both must be\napplied before polyfilling, never after — coarsening a value after it has\nalready been assigned to a fine cell does not undo the exposure that fine\nassignment already created if the fine-grained intermediate was persisted or\nlogged anywhere in the pipeline.\n\n## The privacy_safe profile\n\nThe `privacy_safe` conversion profile\n([conversion-profiles](/docs/conversion-profiles/)) codifies these rules as\ndefaults: `center` containment (a single deterministic assignment per\ndevice, avoiding fractional double-counting across cells), a resolution\npolicy that enforces both a minimum physical cell size and a minimum\naudience threshold, and suppression of any cell that does not clear the\nconfigured threshold after both are applied. Its stated guarantee is that no\ncell is reported below the configured audience/area threshold; its stated\ntradeoff is that resolution degradation and suppression reduce granularity\nin exactly the sparse regions where a naive system would be most exposed.\nAny pipeline reporting `physical_presence` or `home_location` audiences at\nH3 resolution 8 or finer without an explicit reference to this profile\nshould be treated as unaudited."
    },
    {
      "title": "Raster To H3",
      "slug": "raster-to-h3",
      "category": "source-to-h3",
      "summary": "Choosing the correct aggregation statistic when resampling a gridded raster into H3 cells, and why the wrong choice manufactures false precision.",
      "source_geometry": [
        "raster"
      ],
      "destination_geometry": [
        "h3_cell_set"
      ],
      "exactness": "approximate",
      "status": "stable",
      "cell_systems": [
        "h3"
      ],
      "platforms": [
        "generic"
      ],
      "edge_cases": [
        "resolution-mismatch",
        "nodata-values",
        "coastal-pixels"
      ],
      "related": [
        "arbitrary-polygon-to-h3",
        "conversion-quality-metrics",
        "geometry-normalization"
      ],
      "badges": [
        "Approximate"
      ],
      "last_reviewed": "2026-07-22",
      "url": "https://etherdata.ai/blog/geo-interop-kb/docs/raster-to-h3",
      "markdownUrl": "https://etherdata.ai/geo-interop-kb/docs/raster-to-h3.md",
      "markdown": "approximate\n\n## Purpose\n\nRasters — land cover, elevation, imagery-derived classifications, gridded\npopulation estimates — are regular pixel grids, not vector geometry, so\nconverting one to H3 is a resampling problem: each H3 cell must be assigned\na value derived from the (usually several) pixels it overlaps. Which\naggregation statistic is correct depends entirely on what the raster's\nvalues mean — a mean is correct for a continuous field and wrong for a\ncategorical one, and no single default statistic is safe across raster\ntypes.\n\n## Source geometry and destination geometry\n\nSource geometry is `raster`: a regular grid of pixels, each with one or more\nband values, a defined CRS, and a resolution (pixel size) that is usually\nfixed but occasionally coarser than the target H3 resolution. Destination\ngeometry is an `h3_cell_set` where every cell carries one or more derived\nattribute values plus the aggregation method used to produce them.\n\n## Exactness class\n\nApproximate: a cell's assigned value is always a summary of multiple pixel\nvalues (or an extrapolation from a single covering pixel), never a\nmeasurement made at the cell's own scale.\n\n## Containment rule and boundary behavior — aggregation statistics\n\nThe \"containment rule\" for raster conversion is which pixels count toward a\ncell's value and how they are combined:\n\n| | |\n|---|---|\n| Center sample | Value of the pixel containing the cell's center. Cheapest; can miss small features entirely. |\n| Nearest | Value of the pixel whose center is nearest the cell's center; steadier than center-sample when grids are offset. |\n| Mean | Area-weighted or simple average of overlapping pixels. Correct only for continuous, additive fields — meaningless for category codes. |\n| Median | Middle value of overlapping pixels. More outlier-robust than mean for continuous fields; still meaningless for categories. |\n| Min / max | Extremum of overlapping pixels. Used for conservative or worst-case summaries, not central tendency. |\n| Sum | Total of overlapping pixel values, for count- or density-type rasters meant to be additive (e.g. population per pixel). |\n| Majority | Most frequent category among overlapping pixels. The correct default for categorical rasters (land cover, zoning). |\n| Fractional category | Per-category area share among overlapping pixels. Retains what majority discards — a 60/40 forest/water cell is not honestly 'forest'. |\n| Area-weighted | Any statistic above weighted by each pixel's actual overlap area rather than counted whole. Required once pixel size approaches cell size. |\n| Confidence-weighted | Aggregation weighted by a per-pixel confidence/quality band, where the raster ships one. Down-weights low-confidence pixels. |\n\n> Figure (raster-h3): A synthetic smooth field aggregated to a per-cell mean at R8.\n\n## Resolution behavior\n\nThe relationship between pixel size and H3 cell size determines which\nstatistics are even meaningful. When cells are much larger than pixels\n(coarse H3 resolution over fine imagery), mean, median, majority, and\nfractional-category are all well-supported by many pixels per cell. When\ncells are smaller than or comparable to pixels (fine H3 resolution over\ncoarse raster data — the common case for climate or population grids),\nevery statistic collapses toward center-sample or nearest, because a single\npixel dominates or exactly covers the cell, and no aggregation actually\noccurs; reporting a \"mean\" over one pixel is not wrong but implies a\nprecision the data does not have.\n\n## Units and CRS\n\nRasters frequently ship in a projected CRS (UTM zones, Albers Equal Area,\nWeb Mercator) that must be reprojected to EPSG:4326 — or, better, have the\noverlap computed in the raster's native equal-area projection if one is\nused, since equal-area projections keep pixel-area weighting accurate,\nwhile reprojecting to EPSG:4326 first and then area-weighting in\nunprojected degrees is a common source of quiet error. State pixel size in\nmeters at the raster's stated resolution, noting that \"meters per pixel\"\nfor a lat/lon raster varies with latitude unless the raster is already in\nan equal-area or equidistant projection.\n\n## Algorithm\n\n```ts\n\n// Area-weighted mean for a continuous raster band\nfunction rasterCellValue(cellBoundary, rasterBand, stat = \"area-weighted-mean\") {\n  const overlappingPixels = rasterBand.pixelsOverlapping(cellBoundary);\n  switch (stat) {\n    case \"area-weighted-mean\":\n      return weightedAverage(\n        overlappingPixels.map((p) => p.value),\n        overlappingPixels.map((p) => p.overlapAreaM2(cellBoundary)),\n      );\n    case \"majority\":\n      return modeByOverlapArea(overlappingPixels, cellBoundary);\n    case \"fractional-category\":\n      return fractionalCoverageByCategory(overlappingPixels, cellBoundary);\n    default:\n      throw new Error(`unsupported stat: ${stat}`);\n  }\n}\n```\n\nThe same conversion with the Python bindings (`h3-py` v4):\n\n```python\n\nfrom shapely.geometry import Point, Polygon\n\ndef raster_cell_value(cell, raster_band, transform, stat=\"mean\"):\n    # Sample the pixels whose centers fall within the cell's boundary,\n    # then aggregate them per the chosen statistic. h3-py has no raster\n    # helper, so the sampling loop is written directly against the boundary.\n    boundary = Polygon([(lng, lat) for lat, lng in h3.cell_to_boundary(cell)])\n    minx, miny, maxx, maxy = boundary.bounds\n    col_min, row_min = ~transform * (minx, maxy)\n    col_max, row_max = ~transform * (maxx, miny)\n    values, weights = [], []\n    for row in range(int(row_min), int(row_max) + 1):\n        for col in range(int(col_min), int(col_max) + 1):\n            px, py = transform * (col + 0.5, row + 0.5)  # pixel center\n            if boundary.contains(Point(px, py)):\n                values.append(raster_band[row, col])\n                weights.append(1.0)  # swap for pixel-overlap area if area-weighting\n    if not values:\n        return None\n    if stat == \"mean\":\n        return float(np.average(values, weights=weights))\n    if stat == \"majority\":\n        vals, counts = np.unique(values, return_counts=True)\n        return vals[np.argmax(counts)]\n    raise ValueError(f\"unsupported stat: {stat}\")\n\ncells = h3.polygon_to_cells(h3.LatLngPoly(aoi_ring), res=8)\nper_cell_mean = {c: raster_cell_value(c, band, raster_transform) for c in cells}\n```\n\n`h3.cell_to_boundary` gives the polygon to sample against; per-pixel\noverlap-area weighting (rather than the point-count weighting shown above)\nrequires clipping each pixel's own footprint against the boundary with\n`shapely`, exactly as the area-weighted mean does in the TypeScript. The\ntested reference implementation for this conversion is the TypeScript in\n`lib/`.\n\n## Parameters\n\nAggregation statistic (must match the raster's measurement scale —\ncategorical versus continuous), H3 resolution, whether area weighting is\napplied, and which band(s) to aggregate for multi-band rasters.\n\n## Outputs\n\nAn `h3_cell_set` with one or more attribute values per cell, the\naggregation statistic used, the source raster's native resolution and CRS,\nand — for fractional-category output — a nested distribution rather than a\nsingle scalar per cell.\n\n## Quality metrics\n\nReport the pixel-to-cell area ratio (source raster resolution versus H3\ncell area) as the primary diagnostic: a ratio far from 1 signals either\nover-aggregation (many pixels compressed into one statistic, losing\nvariance) or under-aggregation (one pixel stretched across many cells,\nmanufacturing false spatial precision). For categorical rasters, report\nthe majority statistic's own confidence — the winning category's share of\noverlapping-pixel area — since a 34 percent plurality reported as \"the\"\nland cover is materially weaker evidence than a 90 percent majority.\n\n## Edge cases\n\nResolution mismatch is the central failure mode: choosing an H3 resolution\nfiner than the raster's native pixel size does not create information, it\ninterpolates it — every cell within a single source pixel reports an\nidentical value with a false appearance of cell-level precision. Always cap\nthe usable H3 resolution to where pixel-to-cell ratio stays well above 1.\nNodata values (masked pixels, flagged by a sentinel like -9999 or a\nseparate mask band) must be excluded from every statistic explicitly;\naveraging a sentinel into a mean silently corrupts any cell touching a\nmasked pixel, common at tile edges and over water in land-only datasets.\nCoastal pixels compound this: many environmental and demographic rasters\nmask ocean as nodata, so a shoreline cell can have most of its overlapping\npixels excluded, and naive aggregation either extrapolates the land value\nacross the whole cell or wrongly suppresses a cell that is mostly valid\nland. Area-weighted aggregation over only the valid pixels, with the\nvalid-area fraction reported alongside the value, is the correct treatment.\n\n## Assumptions and limitations\n\nThis conversion assumes the raster's value semantics (categorical versus\ncontinuous, additive versus rate) are known before an aggregation statistic\nis chosen; there is no statistic that is safe to apply by default across\nraster types, and applying mean to a categorical raster or majority to a\ncontinuous one produces a value that is syntactically valid and\nsubstantively meaningless. It also assumes the raster's stated resolution\nand CRS metadata are accurate — an unlabeled or mislabeled raster should be\ninspected before conversion, since resolution-mismatch handling depends\nentirely on knowing the true pixel size."
    },
    {
      "title": "Rasters",
      "slug": "rasters",
      "category": "geometries",
      "summary": "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_geometry": [
        "raster"
      ],
      "status": "stable",
      "cell_systems": [
        "h3"
      ],
      "platforms": [
        "generic"
      ],
      "edge_cases": [
        "resolution-mismatch",
        "nodata-values",
        "coastal-pixels"
      ],
      "related": [
        "raster-to-h3",
        "arbitrary-polygons",
        "conversion-quality-metrics"
      ],
      "badges": [],
      "last_reviewed": "2026-07-22",
      "destination_geometry": [],
      "url": "https://etherdata.ai/blog/geo-interop-kb/docs/rasters",
      "markdownUrl": "https://etherdata.ai/geo-interop-kb/docs/rasters.md",
      "markdown": "A raster is a gridded field: a value sampled on a regular array of pixels\ncovering some extent, rather than a discrete shape with a boundary. This\nmakes rasters structurally different from every other family in this\ncatalogue — there is no polygon to polyfill, only a continuous surface that\nmust be resampled onto the H3 grid, and the two grids (fixed-size square\npixels, roughly-equal-area hexagonal cells) never align exactly. Every\nraster-to-H3 conversion is therefore a resampling problem before it is\nanything else, and the resampling method chosen changes the answer as much\nas the source data does.\n\n| | |\n|---|---|\n| Cardinality | A continuous surface sampled on a fixed pixel grid, not a discrete shape |\n| Governed by | Whichever agency or model produced the surface (WorldPop, NOAA, a vendor model) |\n| Not a geometry | A band index or land-cover class code — the surface is the pixel grid itself |\n| Converts via | Resampling (nearest, bilinear, or area-weighted) per cell, not polyfilling |\n\n## Members\n\n| Member | What the surface represents | Typical format |\n|---|---|---|\n| Population | Gridded population count or density | GeoTIFF (e.g. WorldPop) |\n| Elevation | Digital elevation model | GeoTIFF (DEM) |\n| Weather | Temperature, precipitation, wind fields | NetCDF, GRIB |\n| Pollution | Air-quality index or pollutant concentration | GeoTIFF, NetCDF |\n| Land use / land cover | Classified land-cover category per pixel | GeoTIFF (categorical) |\n| Flood depth | Modeled inundation depth | GeoTIFF |\n| Satellite imagery | Multispectral or RGB reflectance | GeoTIFF, COG |\n| Signal strength | Cellular or wireless coverage estimate | GeoTIFF, proprietary grid |\n| Audience-density surfaces | Modeled population or audience concentration | GeoTIFF, proprietary grid |\n\n## Required metadata\n\n| Field | Why it's required |\n|---|---|\n| CRS | Rasters are frequently delivered in a projected CRS (UTM, Albers) and must be reprojected before cell alignment |\n| Native pixel resolution | Determines whether the raster is finer or coarser than the target H3 resolution, which dictates the correct aggregation method |\n| 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 |\n| Band semantics | What each band represents and its units — a raster with unlabeled bands cannot be aggregated correctly regardless of resolution |\n\n## Common risks\n\n**Resolution mismatch** cuts both ways: a coarse raster (say, 1 km\npopulation pixels) sampled onto fine H3 cells (res 9, ~0.1 km²) produces\nfalse precision — many adjacent cells reporting different values that are\nreally the same interpolated or repeated pixel value, implying an accuracy\nthe source data never had. A fine raster (10 m imagery) aggregated onto\ncoarse cells (res 6) needs area-weighted aggregation across every pixel the\ncell covers; averaging without area weighting biases the result toward\nwhichever pixels happen to be enumerated first. **Unmasked no-data\npixels**: if the sentinel value isn't filtered before aggregation, it gets\naveraged in as if it were a real reading, dragging every statistic (mean,\nsum, density) in the sentinel's direction — a `-9999` no-data pixel\nincluded in a mean computation produces a wildly wrong, silently plausible\nnumber. **Coastal and mixed pixels**: pixels straddling land and water, or\nstraddling two land-cover classes, cannot be cleanly labeled at the pixel\nlevel; forcing a single per-cell category onto a boundary cell instead of\nreporting a land-fraction or class-mixture confidence discards real\nuncertainty as false certainty. **False precision generally**: any raster\naggregate reported without a coverage-fraction or valid-pixel-count\nalongside it invites readers to trust a number more than the underlying\ngrid supports.\n\n## How it converts to H3\n\nRasters convert by sampling or aggregating pixel values per cell — nearest,\nbilinear, or area-weighted-mean depending on the resolution relationship\nbetween pixel and cell — documented on\n[raster to H3](/docs/raster-to-h3/), including the specific area-weighting\nalgorithm and how to carry a coverage-fraction and valid-pixel-count\nforward per cell so downstream consumers can see how much of a raster\naggregate is real signal versus interpolation. A raster is never converted\nby extracting contours and polyfilling them as an\n[arbitrary polygon](/docs/arbitrary-polygons/) unless the goal is\nspecifically a categorical boundary (e.g. a flood-extent polygon) derived\nfrom a threshold on the surface — that is a distinct, lossier operation\nfrom full-surface resampling and should be labeled as such."
    },
    {
      "title": "Requested Vs Executed Geography",
      "slug": "requested-vs-executed-geography",
      "category": "concepts",
      "summary": "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.",
      "source_geometry": [],
      "destination_geometry": [],
      "status": "stable",
      "cell_systems": [
        "h3"
      ],
      "platforms": [
        "generic"
      ],
      "edge_cases": [
        "unsupported-exclusions",
        "stale-boundaries"
      ],
      "related": [
        "geographic-interoperability-model",
        "conversion-quality-metrics",
        "inclusion-and-exclusion-semantics"
      ],
      "badges": [],
      "last_reviewed": "2026-07-22",
      "url": "https://etherdata.ai/blog/geo-interop-kb/docs/requested-vs-executed-geography",
      "markdownUrl": "https://etherdata.ai/geo-interop-kb/docs/requested-vs-executed-geography.md",
      "markdown": "\"We targeted the county\" is a sentence that hides at least four decisions:\nwhat geometry represented the county, at what H3 resolution and containment\nrule it was polyfilled, what shape a delivery platform actually executed, and\nwhat geography the delivery report and the outcome attribution used to count\nresults. Each of those is a distinct object. Treating them as one object —\n\"the geography\" — is why coverage numbers, delivered-impression counts, and\nlift estimates disagree without an obvious cause.\n\n## Six named forms\n\n| | |\n|---|---|\n| 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. |\n| 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. |\n| Normalized | The source geometry after CRS detection, reprojection to EPSG:4326, ring closure, winding correction, and self-intersection flagging. See geometry normalization. |\n| 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. |\n| 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. |\n| 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. |\n\n## Lifecycle\n\n```mermaid\nflowchart LR\n  Req[\"Requested\"] --> Src[\"Source\"]\n  Src --> Norm[\"Normalized\"]\n  Norm --> H3c[\"Canonical H3\"]\n  H3c --> Exec[\"Executed\"]\n  Exec --> Rep[\"Reported\"]\n  Rep --> Attr[\"Attributed\"]\n```\n\nEvery arrow is a documented conversion elsewhere in this knowledge base, and\nevery arrow can change the geography's extent, resolution, or family. The\nmodel exists so a claim about any one stage can be checked against the\nadjacent stages rather than assumed to equal them.\n\n## Six concrete divergences\n\n**(a) An H3 cell executed as an outer circle.** A DSP that only accepts\npoint+radius targets receives the [circumscribed circle](/docs/h3-to-circumscribed-circle/)\nof each canonical cell. The circumscribed disk strictly contains the cell —\nevery point of the cell lies inside the disk — but it also covers ground\noutside the cell, and adjacent cells' circles overlap each other. The\nexecuted footprint is measurably larger than, and self-overlapping relative\nto, the canonical H3 set. Reporting \"we targeted the cell\" without stating\nthe circle mode hides both the overreach and the double-eligibility.\n\nThis page has no `ts` algorithm block of its own — the conversions it\nnarrates are each documented (and coded) on their own page. A short\nillustration with the Python bindings (`h3-py` v4) of just case (a), turning\na requested cell set into executed circles:\n\n```python\n\ndef cell_to_circumscribed_circle(cell: str) -> tuple[tuple[float, float], float]:\n    center = h3.cell_to_latlng(cell)\n    # Radius = greatest great-circle distance from center to any boundary\n    # vertex — the smallest circle that still fully contains the cell.\n    radius = max(\n        h3.great_circle_distance(center, vertex, unit=\"m\")\n        for vertex in h3.cell_to_boundary(cell)\n    )\n    return center, radius\n\nrequested_cells = h3.grid_disk(\"872830829ffffff\", 1)\nexecuted_circles = [cell_to_circumscribed_circle(c) for c in requested_cells]\n# requested_cells: 7 discrete, non-overlapping hexagons.\n# executed_circles: 7 overlapping disks whose union is strictly larger than\n# the hexagons' union — requested != executed.\n```\n\nThe tested reference implementation in this knowledge base is the\nTypeScript `circumscribedCircle` in `lib/h3/circles`, which derives the\nradius from a densified boundary (denser sampling near pentagons and\nface-crossing cells) rather than the six raw vertices used above for\nillustration.\n\n> Figure (circles-overlap): H3 cells requested, but executed as circumscribed circles: the executed footprint overlaps and exceeds the cells.\n\n**(b) A DMA polyfilled into H3.** A Nielsen DMA polygon center-polyfilled at\nresolution 7 drops boundary cells whose centers fall just outside the DMA\nline, and — because DMA lines rarely align with H3 cell edges — a\ncenter-contained cell set will disagree with the DMA polygon along its\nentire perimeter, not just at a few points. The canonical H3 set is a\ngenuine partition of the H3 grid, but it is not the DMA; it is the DMA as\nseen through one specific containment rule at one specific resolution.\n\n**(c) H3 mapped back to postal codes.** Crosswalking the canonical cell set\nto ZIP codes for a platform that only accepts postal targeting introduces a\nsecond lossy hop: ZIP codes are USPS delivery routes, not polygons ([an\nidentifier is not a geometry](/docs/geometry-catalogue/)), so the\n\"ZIP polygon\" used is itself a third-party ZCTA approximation. Two\nindependent approximation errors compound — H3-to-DMA, then\nDMA-cells-to-ZIP — and neither is visible in a report that just says\n\"targeted by ZIP.\"\n\n**(d) An exclusion the platform cannot express.** The request is \"include the\nmetro, exclude the stadium.\" If the platform lacks exclusion support, the\ncorrect engineering response is to pre-subtract in cell space —\n`effectiveGeography(inclusions, exclusions, res)` — and target only the\ndifference; the wrong response is to submit the inclusion alone and let the\nexclusion silently vanish. See\n[unsupported exclusions](/docs/inclusion-and-exclusion-semantics/): the\nreported geography and the executed geography will match in that failure\nmode, which is precisely what makes it dangerous — nothing downstream flags\na mismatch, because the platform faithfully executed what it was given.\n\n**(e) Reporting only at state level.** Execution ran at resolution 8\ncircles; the platform's reporting API only breaks delivery out by state.\nThe attributed geography is now two resolution steps coarser than the\ncanonical geography and a different geometry family (admin polygon vs.\nH3-derived circles) than the executed geography. Any lift measured against\nstate-level delivery is measuring a geography that was never actually\ntargeted.\n\n**(f) Cell-level experiment assignment diverging from delivery.** A\ngeo-experiment assigns treatment and control at the cell level using\n[inscribed circles](/docs/h3-to-inscribed-circle/) to guarantee no spill\nbetween neighboring cells. If the media platform instead delivers on\ncircumscribed circles for reach, the executed footprint spills into\nneighboring cells that the experiment design assumed were clean control —\ncontaminating the read without any single stage being \"wrong\" in isolation.\n\n## Provenance has to survive every hop\n\nEach conversion above should append to a `ConversionRecord`, never overwrite\nthe one before it. At minimum the record needs the source CRS and boundary\nvintage, the normalization actions taken, the H3 resolution and containment\nmode, the execution approximation mode (which circle, which simplification\ntolerance, which crosswalk vintage), and which inclusions or exclusions\nwere dropped because a platform could not express them. Losing any one\nfield converts a checkable claim (\"intersect-fill polyfill at resolution 8,\nexecuted as circumscribed circles rounded to platform radius increments\")\ninto an unfalsifiable one (\"we targeted the area\").\n\n> **Note:** Case (b) and (c) both depend on a boundary vintage. A DMA or ZCTA crosswalk\nbuilt from a two-year-old file will misassign cells near any line that has\nsince moved — see [stale boundaries](/docs/geometry-catalogue/) — and that\nmisassignment looks identical, in the data, to a correct crosswalk on an\noutdated boundary. The only defense is recording `validFrom`/`validTo` on\nevery crosswalk and refusing to join across a vintage gap.\n\n> **Note:** Whenever a sentence about geography could be replaced by one of the six rows\nabove, replace it. \"Targeted the county\" becomes \"targeted the resolution-8\nintersect-fill polyfill of the 2024-vintage county boundary, executed as\ncircumscribed circles.\" The second sentence is checkable; the first is not."
    },
    {
      "title": "Resolution Selection",
      "slug": "resolution-selection",
      "category": "concepts",
      "summary": "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.",
      "source_geometry": [],
      "destination_geometry": [],
      "status": "stable",
      "cell_systems": [
        "h3"
      ],
      "platforms": [
        "generic"
      ],
      "edge_cases": [
        "mixed-resolutions",
        "minimum-radius",
        "tiny-polygons"
      ],
      "related": [
        "conversion-profiles",
        "mixed-h3-resolutions",
        "platform-target-count-constraints"
      ],
      "badges": [],
      "last_reviewed": "2026-07-22",
      "url": "https://etherdata.ai/blog/geo-interop-kb/docs/resolution-selection",
      "markdownUrl": "https://etherdata.ai/geo-interop-kb/docs/resolution-selection.md",
      "markdown": "H3 resolution is not a single knob tuned for \"accuracy.\" It sits at the\nintersection of at least twelve independent constraints, several of which\npush in opposite directions. A resolution chosen for boundary fidelity can\nviolate a platform's target-count limit; a resolution chosen to satisfy a\nprivacy threshold can be too coarse for the experimental unit it needs to\nsupport. This page is a decision guide, not a lookup table — the profiles at\nthe end are defaults for common intents, not universal truths.\n\n## The reference table\n\nEach step up in H3 resolution shrinks average cell edge length by roughly a\nfactor of 2.6 and average cell area by roughly a factor of 7 (approximate\nvalues, per the h3geo.org resolution table):\n\n| | |\n|---|---|\n| Res 5 | ~252.9 km2 avg area, ~8.5 km avg edge length |\n| Res 6 | ~36.1 km2 avg area, ~3.2 km avg edge length |\n| Res 7 | ~5.16 km2 avg area, ~1.2 km avg edge length |\n| Res 8 | ~0.737 km2 avg area, ~0.46 km avg edge length |\n| Res 9 | ~0.105 km2 avg area, ~0.17 km avg edge length |\n\nThese are averages over all cells at a resolution, not a per-cell guarantee:\nindividual cells vary in area and edge length depending on their position\nrelative to the icosahedron (see [pentagons](/docs/h3-pentagons/) and\nface-crossing distortion), and the variance grows at coarser resolutions.\nTreat the table as an order-of-magnitude guide for planning, not as a\nper-cell specification.\n\n## The twelve constraints\n\n**Geometry size and boundary complexity.** A resolution should be fine\nenough that the source polygon's boundary is not dominated by a handful of\ncells — a jagged coastline or a county line with many inflections needs a\nfiner resolution than a smooth ellipse of the same area to keep\nboundary-disagreement area small relative to total area.\n\n**Coordinate accuracy.** Resolution finer than the source coordinate\nprecision is false precision. A bidstream ping rounded to two decimal\ndegrees (roughly 1.1 km of latitude error) cannot support resolution 9\n(edge ~170 m) — cap the effective resolution to the coordinate's actual\nprecision, not its nominal one.\n\n**Audience or inventory density.** Sparse-audience geographies need coarser\ncells to accumulate enough observations per cell to be statistically or\nprivacy-meaningfully non-zero; dense urban geographies can support finer\ncells without emptying most of them.\n\n**Platform minimum radius.** If execution will be a point+radius circle,\nthe [inscribed or circumscribed radius](/docs/h3-to-inscribed-circle/) at\nthe chosen resolution must clear the platform's minimum-radius floor —\nresolution 9 cells are frequently too small to produce a radius any DSP will\naccept, forcing a coarser resolution regardless of boundary fidelity.\n\n**Target-count limits.** Platforms cap the number of discrete targets per\nline item. A fine resolution over a large area can produce an inventory of\ncells that exceeds the cap before compaction; see\n[platform target-count constraints](/docs/platform-target-count-constraints/).\n\n**Privacy threshold.** A resolution fine enough to isolate a household\nviolates k-anonymity norms; privacy-safe profiles enforce both a minimum\nphysical cell size and a minimum audience count per reported cell, and will\ncoarsen resolution specifically to clear that floor.\n\n**Experimental unit.** Geo-experiments need units small enough to allow many\nindependent replicates but large enough that adjacent units do not leak\ntreatment into control through normal population movement — usually a\ncoarser resolution (5-7) with a buffer (inscribed circles or gaps) rather\nthan the finest resolution available.\n\n**Reporting granularity.** If the downstream report only breaks out by\nstate or DMA, executing at resolution 9 buys precision that is destroyed at\nthe reporting join — resolution should match the coarsest mandatory\nreporting join in the pipeline, not exceed it for no visible benefit.\n\n**Computation cost.** Cell count grows roughly sevenfold per resolution\nstep; polyfilling, crosswalking, and metric computation over a\ncountry-scale polygon at resolution 9 is a materially larger job than the\nsame polygon at resolution 6, with cost that compounds across every\ndownstream join.\n\n**Population density variance.** A single fixed resolution over both dense\nurban cores and sparse rural areas will over-fragment the city and\nunder-resolve the countryside; this is the core argument for\n[mixed resolutions](/docs/mixed-h3-resolutions/) rather than one resolution\nfor an entire geography.\n\n**Crosswalk stability.** Finer resolutions produce more cells per admin\nregion, each with a smaller intersection fraction, which is more sensitive\nto boundary vintage drift — a crosswalk built for long-term stability\nshould favor a coarser resolution even if a finer one is available.\n\n**Expected inventory.** The number of cells actually available for\ntargeting or measurement after compaction and platform constraints is the\nreal deliverable; resolution choice should be checked against expected\npost-compaction inventory, not against the pre-compaction cell count.\n\n## Recommended profiles — not universal truths\n\n| | |\n|---|---|\n| Admin partition / reporting rollup | Res 7-8: fine enough to track county/DMA boundaries, coarse enough to keep crosswalks stable and inventory manageable. |\n| Store trade-area / proximity targeting | Res 8-9: fine enough to resolve individual retail catchments; verify against platform minimum radius before committing. |\n| Geo-experiment treatment/control | Res 5-7 with inscribed-circle buffering: coarser units reduce control contamination even at some cost to replicate count. |\n| 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. |\n| National-scale planning / DMA-only platforms | Res 4-5: matches city/DMA grain; finer resolution buys nothing a DMA-level platform can express. |\n\n> **Note:** Every row above can be wrong for a specific case. A privacy-safe profile at\nresolution 7 in a dense downtown core may still clear the audience threshold\nat resolution 9; a proximity profile at resolution 9 in a rural trade area\nmay produce mostly empty cells that resolution 7 would have served better.\nCheck the actual constraint list above against the actual geography before\napplying a profile from this table.\n\n## Edge cases\n\nMixed-resolution sets ([mixed-resolutions](/docs/mixed-h3-resolutions/))\narise naturally when different regions of one target need different\nresolutions for density reasons; they must be normalized to a common\nresolution before set operations, never compared as-is. A platform's\nminimum-radius floor ([minimum-radius](/docs/platform-target-count-constraints/))\ncan force a coarser resolution than boundary fidelity alone would choose.\nTiny polygons smaller than a single cell at the chosen resolution may\nreceive zero center-contained cells regardless of how important the target\nis — resolution selection for small trade areas should be checked against\nthe source polygon's actual area, not assumed from a profile.\n\n## Illustration — the same square at three resolutions\n\n> Figure: R7 · 7 cells\n> Figure: R8 · 34 cells\n\n> Figure (poly-r9): R9 · 171 cells — finer resolution hugs the boundary but multiplies the target count roughly 7x per step."
    },
    {
      "title": "S2 Overview",
      "slug": "s2-overview",
      "category": "systems",
      "summary": "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.",
      "source_geometry": [
        "h3_cell_set"
      ],
      "destination_geometry": [
        "h3_cell_set"
      ],
      "exactness": "approximate",
      "status": "stable",
      "cell_systems": [
        "s2"
      ],
      "platforms": [
        "generic"
      ],
      "edge_cases": [
        "face-crossing-cells",
        "mixed-resolutions"
      ],
      "related": [
        "cell-system-comparison",
        "h3-overview"
      ],
      "badges": [
        "Approximate"
      ],
      "last_reviewed": "2026-07-22",
      "url": "https://etherdata.ai/blog/geo-interop-kb/docs/s2-overview",
      "markdownUrl": "https://etherdata.ai/geo-interop-kb/docs/s2-overview.md",
      "markdown": "approximate\n\n## What S2 is\n\nS2 is a discrete global grid system originating at Google. It projects a\ncube onto the sphere — each of the cube's 6 faces maps to a curved\nquadrilateral region of the sphere — and subdivides each face\nhierarchically into smaller quadrilaterals, producing a grid of\nquadrilateral cells at 31 levels, numbered 0 (a whole cube face) through\n30 (finest). Like [H3](/docs/h3-overview/) and\n[Geohash](/docs/geohash-overview/), S2 is one of the non-canonical cell\nsystems this knowledge base's generic model must support without\nassuming H3-specific properties; see\n[cell-system-comparison](/docs/cell-system-comparison/) for the full\ncross-system matrix.\n\n## Hierarchy: exact quad subdivision\n\nS2's defining structural property, and the sharpest contrast with H3, is\nthat its hierarchy is an **exact** quad tree: every cell at level N is\nsubdivided into exactly 4 children at level N+1, and the union of those 4\nchildren's true boundaries exactly reproduces the parent's true boundary,\nwith no gap and no overlap. This is a stronger and qualitatively different\nguarantee than H3's aperture-7 hierarchy, where a parent's 7 (approximate)\nchildren are a logical, index-arithmetic relationship that does not\nexactly geometrically tile the parent. Practically: coarsening or\nrefining an S2 cell set by walking the cell-ID hierarchy reproduces the\nsame covered region exactly, at any level, with no boundary drift —\nwhereas the same operation on an H3 cell set can introduce small boundary\ndiscrepancies that must be measured (`coverage_ratio`, `jaccard`), not\nassumed away.\n\n## Levels and cell size\n\nS2 has 31 levels (0-30). A level-0 cell is one sixth of the sphere's\nsurface (a full cube face); each level down quarters the area of the\nlevel above, so cell area shrinks by a factor of 4 per level, versus H3's\nfactor of roughly 7 per resolution — the two numbering systems are\nindependent and do not correspond level-for-level. S2 is explicitly\n**not** equal-area: the cube-to-sphere projection distorts area\nnon-uniformly across a face, so a cell near a face's center is smaller\nthan a same-level cell near a face edge or corner — comparable in kind,\nthough not magnitude or pattern, to H3's projection-driven area variance.\nNeither S2 nor H3 is equal-area.\n\n## Quadrilateral cells and neighbours\n\nS2 cells are quadrilaterals everywhere — there is no pentagon-equivalent\nstructural exception the way H3 has 12 unavoidable pentagons per\nresolution. A typical S2 cell has 4 edge-adjacent neighbours; cells at a\ncube face's corner or along a face boundary can have additional neighbour\nrelationships to account for the discrete jump between the two adjoining\nfaces' coordinate systems, but this is a face-boundary bookkeeping detail,\nnot a shape exception comparable to an H3 pentagon.\n\n## Index representation: Hilbert curve cell ID\n\nEach S2 cell is addressed by a 64-bit integer whose bits encode the cube\nface (3 bits, for 6 faces) followed by the cell's position along a Hilbert\nspace-filling curve traversal of that face at the target level. The\nHilbert-curve ordering is deliberate: cells that are numerically close in\nS2 cell-ID order are also spatially close on the sphere, a useful locality\nproperty for range-based spatial indexing (a database B-tree or key-range\nscan over S2 cell IDs tends to group nearby cells) — distinct from H3's\nindex, which encodes an explicit resolution/base-cell/digit-path\nstructure rather than a curve position.\n\n## Core operations\n\n| | |\n|---|---|\n| 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`. |\n| 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. |\n| Boundary extraction | Each S2 cell's exact quadrilateral vertex boundary can be recovered from its cell ID and level, the S2 equivalent of `cellToBoundary`. |\n| Centroid extraction | A cell's center point is derivable from its cell ID, analogous to `cellToLatLng`. |\n| 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. |\n\n## Relevance: exact containment for spatial indexing and joins\n\nS2's exact quad containment is specifically why it is a common choice for\nspatial indexing and geometric joins in database and search systems: a\nrange query over Hilbert-ordered S2 cell IDs reliably returns all cells\nwithin a region with no approximation-driven boundary leakage between\nparent and child levels, and \"does cell A contain cell B\" is answerable by\ncomparing cell-ID ranges alone, without a geometric boundary computation.\nH3 cannot offer that same guarantee, because its hierarchy is logical\nrather than exact — which is precisely why this knowledge base recommends\nH3 for advertising execution (where hexagon-shaped catchment approximation\nand aperture-7 resolution steps are the relevant properties) while\nflagging S2 as the stronger choice specifically where exact containment\nis load-bearing, such as backing a spatial join or a range-indexed store.\n\n## What must not be assumed\n\nDo not assume S2 shares H3's resolution numbering, aperture-7 area ratio,\nhexagon shape, 6-neighbour structure, or pentagon exceptions — none of\nthese apply. Do not assume S2 is equal-area — it is not, though its area\nvariance arises from a cube projection rather than an icosahedron\nprojection. Do assume S2's parent-child containment is exact, which is the\none hierarchy property that is *stronger*, not weaker, than H3's.\n\n## References\n\n- S2 Geometry — Google, [s2geometry.io](https://s2geometry.io/) (last\n  verified 2026-07-22)\n\n## Assumptions and limitations\n\nThis page describes S2's generic capability model as registered in\n`data/cell-systems.yaml`. Exact level count, cell-ID bit layout, and\n`S2RegionCoverer` parameter defaults should be verified against the\ncurrent S2 library documentation before being relied on for a production\ncalculation.\n\n## Illustration — a real S2 cell\n\n> Figure (cell-s2): An actual S2 cell (s2-geometry) at level 13: a quadrilateral with 4 edge neighbours; 4 children exactly tile it."
    }
  ],
  "edgeCases": [
    {
      "id": "antimeridian",
      "fixture": "antimeridian",
      "name": "Antimeridian (±180° meridian)",
      "category": "global",
      "description": "Geometries crossing ±180° longitude wrap incorrectly, producing world-spanning artifacts when treated as planar.",
      "affectedConversions": [
        "h3-to-polygon",
        "polygon-to-h3-intersect",
        "point-radius-to-h3"
      ],
      "detectionMethod": "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"
      ],
      "page": "antimeridian-handling",
      "url": "https://etherdata.ai/blog/geo-interop-kb/edge-cases/antimeridian"
    },
    {
      "id": "pentagons",
      "fixture": "pentagons-world",
      "name": "H3 pentagons",
      "category": "global",
      "description": "12 pentagon cells per resolution sit at icosahedron vertices; they break the 6-neighbour and regular-shape assumptions and have lower inscribed/circumscribed ratios.",
      "affectedConversions": [
        "h3-to-inscribed-circle",
        "h3-to-circumscribed-circle",
        "h3-to-polygon"
      ],
      "detectionMethod": "isPentagon(cell).",
      "mitigation": [
        "Handle pentagons explicitly",
        "Do not assume a hexagon inscribed ratio of cos(30°)",
        "Report per-cell shape regularity"
      ],
      "page": "h3-pentagons",
      "url": "https://etherdata.ai/blog/geo-interop-kb/edge-cases/pentagons"
    },
    {
      "id": "face-crossing-cells",
      "fixture": "pentagon-cell",
      "name": "Icosahedron face-crossing cells",
      "category": "global",
      "description": "Cells spanning two icosahedron faces are distorted; edges are not symmetric and area varies.",
      "affectedConversions": [
        "h3-to-polygon",
        "h3-to-inscribed-circle"
      ],
      "detectionMethod": "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"
      ],
      "url": "https://etherdata.ai/blog/geo-interop-kb/edge-cases/face-crossing-cells"
    },
    {
      "id": "self-intersections",
      "name": "Self-intersecting polygon",
      "category": "geometry",
      "description": "Bowtie/overlapping rings make area and containment undefined.",
      "affectedConversions": [
        "polygon-to-h3-intersect",
        "polygon-to-h3-full"
      ],
      "detectionMethod": "turf.kinks returns intersection points.",
      "mitigation": [
        "Repair upstream (buffer(0) / make-valid)",
        "Reject and flag rather than silently fill"
      ],
      "page": "geometry-normalization",
      "url": "https://etherdata.ai/blog/geo-interop-kb/edge-cases/self-intersections"
    },
    {
      "id": "holes",
      "fixture": "polygon-hole",
      "name": "Polygon holes",
      "category": "geometry",
      "description": "Interior rings (donuts) must be respected so cells inside a hole are excluded.",
      "affectedConversions": [
        "polygon-to-h3-center",
        "polygon-to-h3-full",
        "polygon-to-h3-intersect"
      ],
      "detectionMethod": "Polygon has > 1 ring.",
      "mitigation": [
        "Pass all rings to the filler",
        "Verify hole winding (CW) after normalization"
      ],
      "page": "geometry-normalization",
      "url": "https://etherdata.ai/blog/geo-interop-kb/edge-cases/holes"
    },
    {
      "id": "narrow-polygons",
      "fixture": "ec-narrow-polygon",
      "name": "Narrow / sliver polygons",
      "category": "geometry",
      "description": "Polygons thinner than a cell can yield zero center-contained cells.",
      "affectedConversions": [
        "polygon-to-h3-center",
        "polygon-to-h3-full"
      ],
      "detectionMethod": "Center-fill returns empty while area > 0.",
      "mitigation": [
        "Use intersect mode",
        "Increase resolution",
        "Seed from boundary vertices"
      ],
      "url": "https://etherdata.ai/blog/geo-interop-kb/edge-cases/narrow-polygons"
    },
    {
      "id": "tiny-polygons",
      "name": "Tiny polygons",
      "category": "geometry",
      "description": "Polygons much smaller than a cell may be missed or over-represented by a single cell.",
      "affectedConversions": [
        "polygon-to-h3-center",
        "polygon-to-h3-intersect"
      ],
      "detectionMethod": "Polygon area << cell area at the chosen resolution.",
      "mitigation": [
        "Increase resolution",
        "Consider point+radius instead"
      ],
      "url": "https://etherdata.ai/blog/geo-interop-kb/edge-cases/tiny-polygons"
    },
    {
      "id": "touching-only",
      "fixture": "ec-touching-only",
      "name": "Touching-only intersection",
      "category": "geometry",
      "description": "A cell that only shares a boundary point/edge (zero area) with the polygon.",
      "affectedConversions": [
        "polygon-to-h3-intersect"
      ],
      "detectionMethod": "Intersection area ≈ 0 despite booleanIntersects true.",
      "mitigation": [
        "Require intersection_area > ε, not mere touching"
      ],
      "url": "https://etherdata.ai/blog/geo-interop-kb/edge-cases/touching-only"
    },
    {
      "id": "simplified-boundaries",
      "name": "Simplified boundaries",
      "category": "geometry",
      "description": "Douglas-Peucker-style simplification shifts the boundary, moving which cells qualify.",
      "affectedConversions": [
        "polygon-to-h3-intersect",
        "polygon-to-h3-threshold"
      ],
      "detectionMethod": "Compare vertex counts / boundary displacement against source.",
      "mitigation": [
        "Record simplification tolerance",
        "Report boundary displacement metric"
      ],
      "url": "https://etherdata.ai/blog/geo-interop-kb/edge-cases/simplified-boundaries"
    },
    {
      "id": "multipart-geometries",
      "name": "MultiPolygon / multipart",
      "category": "geometry",
      "description": "Disjoint parts (islands, exclaves) must all be filled; a single-ring assumption drops parts.",
      "affectedConversions": [
        "polygon-to-h3-intersect",
        "admin-to-h3-weighted"
      ],
      "detectionMethod": "Geometry type is MultiPolygon.",
      "mitigation": [
        "Iterate all parts",
        "Preserve part membership in provenance"
      ],
      "page": "geometry-normalization",
      "url": "https://etherdata.ai/blog/geo-interop-kb/edge-cases/multipart-geometries"
    },
    {
      "id": "mixed-resolutions",
      "fixture": "mixed-resolution",
      "name": "Mixed-resolution cell set",
      "category": "hierarchy",
      "description": "A set mixing resolutions cannot be compared or subtracted without normalization.",
      "affectedConversions": [
        "cellsystem-to-cellsystem",
        "h3-to-platform-id"
      ],
      "detectionMethod": "getResolution differs across the set.",
      "mitigation": [
        "normalizeToResolution before set ops",
        "Decide compaction policy explicitly"
      ],
      "page": "mixed-h3-resolutions",
      "url": "https://etherdata.ai/blog/geo-interop-kb/edge-cases/mixed-resolutions"
    },
    {
      "id": "parent-child-duplicates",
      "fixture": "mixed-resolution",
      "name": "Parent + child in one set",
      "category": "hierarchy",
      "description": "A parent and one of its descendants both present double-count the shared area.",
      "affectedConversions": [
        "cellsystem-to-cellsystem"
      ],
      "detectionMethod": "hasParentChildDuplicate (ancestor present in set).",
      "mitigation": [
        "Compact then uncompact to a target resolution",
        "Normalize before union/difference"
      ],
      "page": "h3-compaction-and-uncompaction",
      "url": "https://etherdata.ai/blog/geo-interop-kb/edge-cases/parent-child-duplicates"
    },
    {
      "id": "minimum-radius",
      "fixture": "min-radius-floor",
      "name": "Platform minimum radius",
      "category": "platform",
      "description": "A platform floor (e.g. 1 km) makes sub-floor cells un-executable as circles.",
      "affectedConversions": [
        "h3-to-inscribed-circle",
        "h3-to-circumscribed-circle",
        "point-radius-to-h3"
      ],
      "detectionMethod": "circle radius < platform.minRadius.",
      "mitigation": [
        "Coarsen resolution until inscribed radius ≥ min",
        "Merge cells before circling"
      ],
      "page": "platform-target-count-constraints",
      "url": "https://etherdata.ai/blog/geo-interop-kb/edge-cases/minimum-radius"
    },
    {
      "id": "radius-increments",
      "name": "Radius rounding / increments",
      "category": "platform",
      "description": "Platforms round radii to increments, changing coverage/overlap.",
      "affectedConversions": [
        "h3-to-circumscribed-circle",
        "h3-to-inscribed-circle"
      ],
      "detectionMethod": "Compare requested radius to platform-quantized radius.",
      "mitigation": [
        "Round outward for coverage, inward for isolation",
        "Recompute metrics on the rounded radius"
      ],
      "url": "https://etherdata.ai/blog/geo-interop-kb/edge-cases/radius-increments"
    },
    {
      "id": "platform-native-ids-only",
      "name": "Platform accepts only native IDs",
      "category": "platform",
      "description": "No polygons/coordinates — everything must be crosswalked to platform IDs, losing sub-unit precision.",
      "affectedConversions": [
        "h3-to-platform-id"
      ],
      "detectionMethod": "platform.polygonSupport=false and coordinateSupport=false.",
      "mitigation": [
        "Maintain a versioned crosswalk",
        "Report unmatched cells and precision loss"
      ],
      "page": "h3-to-platform-native-geography",
      "url": "https://etherdata.ai/blog/geo-interop-kb/edge-cases/platform-native-ids-only"
    },
    {
      "id": "unsupported-exclusions",
      "fixture": "include-exclude",
      "name": "Unsupported exclusions",
      "category": "platform",
      "description": "'Include A minus B' cannot be expressed on platforms without exclusion support; the exclusion is silently dropped.",
      "affectedConversions": [
        "h3-to-platform-id"
      ],
      "detectionMethod": "platform.exclusionSupport=false with a non-empty exclusion set.",
      "mitigation": [
        "Pre-subtract in cell space and target only the difference",
        "Flag reported≠executed"
      ],
      "page": "inclusion-and-exclusion-semantics",
      "url": "https://etherdata.ai/blog/geo-interop-kb/edge-cases/unsupported-exclusions"
    },
    {
      "id": "axis-order-reversal",
      "fixture": "ec-axis-swap",
      "name": "Axis-order reversal (lat/lng swap)",
      "category": "data_quality",
      "description": "Coordinates supplied as [lat,lng] where [lng,lat] is expected place geometry in the wrong hemisphere.",
      "affectedConversions": [
        "point-to-h3",
        "polygon-to-h3-intersect"
      ],
      "detectionMethod": "Coordinate out of range but valid when swapped.",
      "mitigation": [
        "Range-check and swap-detect",
        "Reject rather than guess when ambiguous"
      ],
      "page": "coordinate-and-crs-failures",
      "url": "https://etherdata.ai/blog/geo-interop-kb/edge-cases/axis-order-reversal"
    },
    {
      "id": "rounded-coordinates",
      "name": "Rounded / truncated coordinates",
      "category": "data_quality",
      "description": "Bidstream coordinates rounded to 2–3 decimals snap to a coarse grid, biasing cell assignment.",
      "affectedConversions": [
        "point-to-h3"
      ],
      "detectionMethod": "Coordinate precision below the cell edge length.",
      "mitigation": [
        "Cap effective resolution to the coordinate precision",
        "Treat as point+accuracy, not exact"
      ],
      "page": "coordinate-and-crs-failures",
      "url": "https://etherdata.ai/blog/geo-interop-kb/edge-cases/rounded-coordinates"
    },
    {
      "id": "nodata-values",
      "name": "Raster no-data values",
      "category": "data_quality",
      "description": "Sentinel no-data pixels (e.g. -9999) corrupt aggregates if not masked.",
      "affectedConversions": [
        "raster-to-h3"
      ],
      "detectionMethod": "Pixel equals the declared nodata value.",
      "mitigation": [
        "Mask nodata before aggregation",
        "Record coverage fraction of valid pixels per cell"
      ],
      "page": "raster-to-h3",
      "url": "https://etherdata.ai/blog/geo-interop-kb/edge-cases/nodata-values"
    },
    {
      "id": "resolution-mismatch",
      "name": "Raster/cell resolution mismatch",
      "category": "data_quality",
      "description": "A coarse raster over fine cells yields false precision; fine raster over coarse cells needs area weighting.",
      "affectedConversions": [
        "raster-to-h3"
      ],
      "detectionMethod": "Compare pixel size to cell edge length.",
      "mitigation": [
        "Area-weighted aggregation",
        "Cap cell resolution to raster resolution"
      ],
      "page": "raster-to-h3",
      "url": "https://etherdata.ai/blog/geo-interop-kb/edge-cases/resolution-mismatch"
    },
    {
      "id": "coastal-pixels",
      "name": "Coastal / mixed pixels",
      "category": "data_quality",
      "description": "Pixels straddling land/water mislabel coastal cells.",
      "affectedConversions": [
        "raster-to-h3"
      ],
      "detectionMethod": "Land mask disagreement within a cell.",
      "mitigation": [
        "Apply a land/water mask",
        "Report per-cell land fraction"
      ],
      "url": "https://etherdata.ai/blog/geo-interop-kb/edge-cases/coastal-pixels"
    },
    {
      "id": "stale-boundaries",
      "name": "Stale boundaries",
      "category": "temporal",
      "description": "Admin/postal/DMA boundaries change; using an old vintage misassigns cells.",
      "affectedConversions": [
        "admin-to-h3-maxoverlap",
        "admin-to-h3-weighted",
        "h3-to-platform-id"
      ],
      "detectionMethod": "Boundary vintage older than the activity period.",
      "mitigation": [
        "Version boundaries",
        "Record validFrom/validTo on every crosswalk"
      ],
      "page": "requested-vs-executed-geography",
      "url": "https://etherdata.ai/blog/geo-interop-kb/edge-cases/stale-boundaries"
    },
    {
      "id": "dma-changes",
      "name": "DMA redefinition",
      "category": "temporal",
      "description": "Media markets are periodically redrawn; crosswalks must be pinned to a vintage.",
      "affectedConversions": [
        "admin-to-h3-weighted"
      ],
      "detectionMethod": "Vendor vintage change.",
      "mitigation": [
        "Pin DMA vintage",
        "Re-run crosswalk on redefinition"
      ],
      "url": "https://etherdata.ai/blog/geo-interop-kb/edge-cases/dma-changes"
    },
    {
      "id": "postal-boundary-changes",
      "name": "Postal boundary changes",
      "category": "temporal",
      "description": "ZIP/ZCTA definitions drift between vintages.",
      "affectedConversions": [
        "admin-to-h3-weighted"
      ],
      "detectionMethod": "ZCTA vintage mismatch.",
      "mitigation": [
        "Pin vintage",
        "Prefer point-set semantics where ZIP is not a polygon"
      ],
      "url": "https://etherdata.ai/blog/geo-interop-kb/edge-cases/postal-boundary-changes"
    },
    {
      "id": "duplicated-region-ids",
      "name": "Duplicated region IDs",
      "category": "data_quality",
      "description": "The same admin id mapping to multiple polygons (data error) breaks partition assumptions.",
      "affectedConversions": [
        "admin-to-h3-maxoverlap",
        "admin-to-h3-weighted"
      ],
      "detectionMethod": "id appears on > 1 disjoint feature unexpectedly.",
      "mitigation": [
        "Dedupe / union by id",
        "Fail loudly on unexpected duplicates"
      ],
      "url": "https://etherdata.ai/blog/geo-interop-kb/edge-cases/duplicated-region-ids"
    },
    {
      "id": "ip-derived-location",
      "name": "IP-derived location",
      "category": "advertising",
      "description": "IP geolocation is coarse and often centroid-biased; it is not physical presence.",
      "affectedConversions": [
        "point-to-h3"
      ],
      "detectionMethod": "locationSource = ip.",
      "mitigation": [
        "Cap resolution",
        "Label matching semantic explicitly (not physical_presence)"
      ],
      "page": "advertising-geographic-matching-semantics",
      "url": "https://etherdata.ai/blog/geo-interop-kb/edge-cases/ip-derived-location"
    },
    {
      "id": "consent-precision",
      "name": "Consent-based precision reduction",
      "category": "privacy",
      "description": "Consent state can coarsen or drop coordinates, changing cell assignment.",
      "affectedConversions": [
        "point-to-h3",
        "point-radius-to-h3"
      ],
      "detectionMethod": "Consent flag indicates reduced precision.",
      "mitigation": [
        "Degrade resolution to match consent",
        "Never up-sample coarsened data"
      ],
      "page": "privacy-and-minimum-aggregation",
      "url": "https://etherdata.ai/blog/geo-interop-kb/edge-cases/consent-precision"
    },
    {
      "id": "sparse-audience-suppression",
      "name": "Sparse-audience suppression",
      "category": "privacy",
      "description": "Cells with too few users risk re-identification and must be suppressed or coarsened.",
      "affectedConversions": [
        "point-to-h3",
        "raster-to-h3"
      ],
      "detectionMethod": "Per-cell audience below k-anonymity threshold.",
      "mitigation": [
        "Suppress or roll up to a coarser resolution",
        "Enforce minimum aggregation window"
      ],
      "page": "privacy-and-minimum-aggregation",
      "url": "https://etherdata.ai/blog/geo-interop-kb/edge-cases/sparse-audience-suppression"
    },
    {
      "id": "device-trajectory-exposure",
      "name": "Device trajectory exposure",
      "category": "privacy",
      "description": "Ordered high-resolution trajectories are re-identifying even when individual points are not.",
      "affectedConversions": [
        "line-to-h3"
      ],
      "detectionMethod": "Trajectory uniqueness at the chosen resolution.",
      "mitigation": [
        "Coarsen space/time",
        "Drop order or aggregate to OD pairs"
      ],
      "page": "privacy-and-minimum-aggregation",
      "url": "https://etherdata.ai/blog/geo-interop-kb/edge-cases/device-trajectory-exposure"
    },
    {
      "id": "gps-noise",
      "name": "GPS noise / boundary oscillation",
      "category": "advertising",
      "description": "Noisy fixes near a boundary flip cells back and forth, inflating counts.",
      "affectedConversions": [
        "line-to-h3",
        "point-to-h3"
      ],
      "detectionMethod": "High-frequency cell alternation within the accuracy radius.",
      "mitigation": [
        "Smooth trajectories",
        "Snap using accuracy radius",
        "Debounce boundary crossings"
      ],
      "url": "https://etherdata.ai/blog/geo-interop-kb/edge-cases/gps-noise"
    },
    {
      "id": "boundary-oscillation",
      "name": "Corridor boundary oscillation",
      "category": "advertising",
      "description": "A path skimming a cell edge produces a jagged, duplicated cell sequence.",
      "affectedConversions": [
        "line-to-h3"
      ],
      "detectionMethod": "Repeated A→B→A cell transitions.",
      "mitigation": [
        "Buffer the corridor",
        "Deduplicate consecutive repeats"
      ],
      "url": "https://etherdata.ai/blog/geo-interop-kb/edge-cases/boundary-oscillation"
    },
    {
      "id": "duplicate-observations",
      "name": "Duplicate observations",
      "category": "measurement",
      "description": "The same impression/visit counted multiple times inflates audience per cell.",
      "affectedConversions": [
        "point-to-h3"
      ],
      "detectionMethod": "Repeated dedup keys within a window.",
      "mitigation": [
        "Deduplicate by key+window before aggregation"
      ],
      "url": "https://etherdata.ai/blog/geo-interop-kb/edge-cases/duplicate-observations"
    },
    {
      "id": "nested-holes",
      "fixture": "polygon-hole",
      "name": "Nested holes",
      "category": "geometry",
      "description": "A hole containing an island (hole-in-hole-in-fill) violates simple-polygon assumptions; naive ring parity gets the interior/exterior classification backwards.",
      "affectedConversions": [
        "polygon-to-h3-center",
        "polygon-to-h3-full",
        "polygon-to-h3-intersect"
      ],
      "detectionMethod": "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"
      ],
      "url": "https://etherdata.ai/blog/geo-interop-kb/edge-cases/nested-holes"
    },
    {
      "id": "duplicate-vertices",
      "name": "Duplicate vertices",
      "category": "geometry",
      "description": "Consecutive repeated coordinates (zero-length segments) in a ring can produce degenerate edges that break area and intersection calculations.",
      "affectedConversions": [
        "polygon-to-h3-intersect",
        "polygon-to-h3-threshold",
        "line-to-h3"
      ],
      "detectionMethod": "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"
      ],
      "url": "https://etherdata.ai/blog/geo-interop-kb/edge-cases/duplicate-vertices"
    },
    {
      "id": "unclosed-rings",
      "name": "Unclosed rings",
      "category": "geometry",
      "description": "A polygon ring whose first and last coordinates differ is not technically closed, causing GeoJSON-strict fillers to throw or silently miscompute area.",
      "affectedConversions": [
        "polygon-to-h3-center",
        "polygon-to-h3-full",
        "polygon-to-h3-intersect"
      ],
      "detectionMethod": "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"
      ],
      "url": "https://etherdata.ai/blog/geo-interop-kb/edge-cases/unclosed-rings"
    },
    {
      "id": "incorrect-winding",
      "name": "Incorrect ring winding order",
      "category": "geometry",
      "description": "GeoJSON requires exterior rings counter-clockwise and holes clockwise (right-hand rule); reversed winding flips interior/exterior for winding-sensitive fillers.",
      "affectedConversions": [
        "polygon-to-h3-center",
        "polygon-to-h3-full",
        "polygon-to-h3-intersect"
      ],
      "detectionMethod": "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"
      ],
      "url": "https://etherdata.ai/blog/geo-interop-kb/edge-cases/incorrect-winding"
    },
    {
      "id": "empty-geometries",
      "name": "Empty geometries",
      "category": "geometry",
      "description": "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.",
      "affectedConversions": [
        "polygon-to-h3-center",
        "polygon-to-h3-full",
        "polygon-to-h3-intersect",
        "admin-to-h3-maxoverlap"
      ],
      "detectionMethod": "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"
      ],
      "url": "https://etherdata.ai/blog/geo-interop-kb/edge-cases/empty-geometries"
    },
    {
      "id": "invalid-coordinates",
      "name": "Invalid coordinate values",
      "category": "geometry",
      "description": "NaN, Infinity, out-of-range (|lat|>90, |lng|>180), or null coordinate components crash or silently corrupt downstream H3 indexing.",
      "affectedConversions": [
        "point-to-h3",
        "polygon-to-h3-intersect",
        "line-to-h3",
        "point-radius-to-h3"
      ],
      "detectionMethod": "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"
      ],
      "url": "https://etherdata.ai/blog/geo-interop-kb/edge-cases/invalid-coordinates"
    },
    {
      "id": "poles",
      "fixture": "ec-poles",
      "name": "Polar singularities",
      "category": "global",
      "description": "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.",
      "affectedConversions": [
        "h3-to-polygon",
        "h3-to-inscribed-circle",
        "h3-to-circumscribed-circle",
        "point-radius-to-h3"
      ],
      "detectionMethod": "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"
      ],
      "url": "https://etherdata.ai/blog/geo-interop-kb/edge-cases/poles"
    },
    {
      "id": "coastal-boundaries",
      "fixture": "ec-water-cells",
      "name": "Coastal boundary ambiguity",
      "category": "global",
      "description": "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.",
      "affectedConversions": [
        "polygon-to-h3-intersect",
        "admin-to-h3-maxoverlap",
        "raster-to-h3"
      ],
      "detectionMethod": "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"
      ],
      "url": "https://etherdata.ai/blog/geo-interop-kb/edge-cases/coastal-boundaries"
    },
    {
      "id": "offshore-islands",
      "name": "Offshore islands",
      "category": "global",
      "description": "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.",
      "affectedConversions": [
        "polygon-to-h3-intersect",
        "admin-to-h3-maxoverlap",
        "admin-to-h3-weighted"
      ],
      "detectionMethod": "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"
      ],
      "url": "https://etherdata.ai/blog/geo-interop-kb/edge-cases/offshore-islands"
    },
    {
      "id": "enclaves",
      "name": "Enclaves",
      "category": "global",
      "description": "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.",
      "affectedConversions": [
        "admin-to-h3-maxoverlap",
        "admin-to-h3-weighted",
        "h3-to-platform-id"
      ],
      "detectionMethod": "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"
      ],
      "url": "https://etherdata.ai/blog/geo-interop-kb/edge-cases/enclaves"
    },
    {
      "id": "exclaves",
      "name": "Exclaves",
      "category": "global",
      "description": "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.",
      "affectedConversions": [
        "admin-to-h3-maxoverlap",
        "admin-to-h3-weighted"
      ],
      "detectionMethod": "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"
      ],
      "url": "https://etherdata.ai/blog/geo-interop-kb/edge-cases/exclaves"
    },
    {
      "id": "disputed-territories",
      "name": "Disputed territories",
      "category": "global",
      "description": "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.",
      "affectedConversions": [
        "admin-to-h3-maxoverlap",
        "admin-to-h3-weighted",
        "h3-to-platform-id"
      ],
      "detectionMethod": "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"
      ],
      "url": "https://etherdata.ai/blog/geo-interop-kb/edge-cases/disputed-territories"
    },
    {
      "id": "water-cells",
      "fixture": "ec-water-cells",
      "name": "Water-only cells",
      "category": "global",
      "description": "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.",
      "affectedConversions": [
        "polygon-to-h3-intersect",
        "point-radius-to-h3",
        "raster-to-h3"
      ],
      "detectionMethod": "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"
      ],
      "url": "https://etherdata.ai/blog/geo-interop-kb/edge-cases/water-cells"
    },
    {
      "id": "geodesic-vs-planar",
      "fixture": "ec-geodesic-planar",
      "name": "Geodesic versus planar geometry",
      "category": "global",
      "description": "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.",
      "affectedConversions": [
        "point-radius-to-h3",
        "h3-to-inscribed-circle",
        "h3-to-circumscribed-circle",
        "line-to-h3"
      ],
      "detectionMethod": "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"
      ],
      "url": "https://etherdata.ai/blog/geo-interop-kb/edge-cases/geodesic-vs-planar"
    },
    {
      "id": "web-mercator-distortion",
      "name": "Web Mercator distortion",
      "category": "global",
      "description": "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.",
      "affectedConversions": [
        "h3-to-polygon",
        "h3-to-circumscribed-circle",
        "raster-to-h3"
      ],
      "detectionMethod": "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"
      ],
      "url": "https://etherdata.ai/blog/geo-interop-kb/edge-cases/web-mercator-distortion"
    },
    {
      "id": "wrong-crs",
      "name": "Wrong CRS assumed",
      "category": "data_quality",
      "description": "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.",
      "affectedConversions": [
        "point-to-h3",
        "polygon-to-h3-intersect"
      ],
      "detectionMethod": "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"
      ],
      "url": "https://etherdata.ai/blog/geo-interop-kb/edge-cases/wrong-crs"
    },
    {
      "id": "missing-crs",
      "name": "Missing CRS declaration",
      "category": "data_quality",
      "description": "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.",
      "affectedConversions": [
        "polygon-to-h3-intersect",
        "point-to-h3",
        "raster-to-h3"
      ],
      "detectionMethod": "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"
      ],
      "url": "https://etherdata.ai/blog/geo-interop-kb/edge-cases/missing-crs"
    },
    {
      "id": "missing-polygons",
      "name": "Missing polygons",
      "category": "data_quality",
      "description": "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.",
      "affectedConversions": [
        "admin-to-h3-maxoverlap",
        "admin-to-h3-weighted",
        "polygon-to-h3-intersect"
      ],
      "detectionMethod": "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"
      ],
      "url": "https://etherdata.ai/blog/geo-interop-kb/edge-cases/missing-polygons"
    },
    {
      "id": "incomplete-geometry",
      "name": "Incomplete geometry",
      "category": "data_quality",
      "description": "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.",
      "affectedConversions": [
        "polygon-to-h3-center",
        "polygon-to-h3-full",
        "polygon-to-h3-intersect"
      ],
      "detectionMethod": "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"
      ],
      "url": "https://etherdata.ai/blog/geo-interop-kb/edge-cases/incomplete-geometry"
    },
    {
      "id": "geocoding-uncertainty",
      "name": "Geocoding uncertainty",
      "category": "data_quality",
      "description": "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.",
      "affectedConversions": [
        "point-to-h3"
      ],
      "detectionMethod": "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"
      ],
      "url": "https://etherdata.ai/blog/geo-interop-kb/edge-cases/geocoding-uncertainty"
    },
    {
      "id": "zero-island",
      "fixture": "ec-zero-island",
      "name": "Zero-island coordinates",
      "category": "data_quality",
      "description": "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.",
      "affectedConversions": [
        "point-to-h3"
      ],
      "detectionMethod": "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"
      ],
      "url": "https://etherdata.ai/blog/geo-interop-kb/edge-cases/zero-island"
    },
    {
      "id": "compacted-sets",
      "name": "Compacted cell sets",
      "category": "hierarchy",
      "description": "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.",
      "affectedConversions": [
        "cellsystem-to-cellsystem",
        "h3-to-platform-id"
      ],
      "detectionMethod": "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"
      ],
      "url": "https://etherdata.ai/blog/geo-interop-kb/edge-cases/compacted-sets"
    },
    {
      "id": "logical-vs-geometric-containment",
      "name": "Logical versus geometric containment",
      "category": "hierarchy",
      "description": "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.",
      "affectedConversions": [
        "cellsystem-to-cellsystem"
      ],
      "detectionMethod": "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"
      ],
      "url": "https://etherdata.ai/blog/geo-interop-kb/edge-cases/logical-vs-geometric-containment"
    },
    {
      "id": "resolution-coercion",
      "name": "Resolution coercion",
      "category": "hierarchy",
      "description": "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.",
      "affectedConversions": [
        "cellsystem-to-cellsystem",
        "admin-to-h3-weighted"
      ],
      "detectionMethod": "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"
      ],
      "url": "https://etherdata.ai/blog/geo-interop-kb/edge-cases/resolution-coercion"
    },
    {
      "id": "incomplete-child-sets",
      "name": "Incomplete child sets",
      "category": "hierarchy",
      "description": "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.",
      "affectedConversions": [
        "cellsystem-to-cellsystem"
      ],
      "detectionMethod": "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"
      ],
      "url": "https://etherdata.ai/blog/geo-interop-kb/edge-cases/incomplete-child-sets"
    },
    {
      "id": "maximum-radius",
      "name": "Platform maximum radius",
      "category": "platform",
      "description": "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.",
      "affectedConversions": [
        "h3-to-circumscribed-circle",
        "h3-to-equal-area-circle",
        "point-radius-to-h3"
      ],
      "detectionMethod": "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"
      ],
      "url": "https://etherdata.ai/blog/geo-interop-kb/edge-cases/maximum-radius"
    },
    {
      "id": "unsupported-polygons",
      "name": "Unsupported polygon targeting",
      "category": "platform",
      "description": "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.",
      "affectedConversions": [
        "h3-to-polygon",
        "h3-to-platform-id"
      ],
      "detectionMethod": "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"
      ],
      "url": "https://etherdata.ai/blog/geo-interop-kb/edge-cases/unsupported-polygons"
    },
    {
      "id": "coordinate-rounding",
      "name": "Platform coordinate rounding",
      "category": "platform",
      "description": "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.",
      "affectedConversions": [
        "point-radius-to-h3",
        "h3-to-circumscribed-circle"
      ],
      "detectionMethod": "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"
      ],
      "url": "https://etherdata.ai/blog/geo-interop-kb/edge-cases/coordinate-rounding"
    },
    {
      "id": "undocumented-deduplication",
      "name": "Undocumented deduplication",
      "category": "platform",
      "description": "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.",
      "affectedConversions": [
        "h3-to-platform-id",
        "cellsystem-to-cellsystem"
      ],
      "detectionMethod": "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"
      ],
      "url": "https://etherdata.ai/blog/geo-interop-kb/edge-cases/undocumented-deduplication"
    },
    {
      "id": "optimized-targeting-expansion",
      "name": "Optimized targeting expansion",
      "category": "platform",
      "description": "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.",
      "affectedConversions": [
        "h3-to-platform-id",
        "point-radius-to-h3"
      ],
      "detectionMethod": "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"
      ],
      "url": "https://etherdata.ai/blog/geo-interop-kb/edge-cases/optimized-targeting-expansion"
    },
    {
      "id": "reporting-coarser-level",
      "name": "Reporting at a coarser level than execution",
      "category": "platform",
      "description": "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.",
      "affectedConversions": [
        "h3-to-platform-id",
        "admin-to-h3-weighted"
      ],
      "detectionMethod": "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"
      ],
      "url": "https://etherdata.ai/blog/geo-interop-kb/edge-cases/reporting-coarser-level"
    },
    {
      "id": "asynchronous-boundary-updates",
      "name": "Asynchronous boundary updates",
      "category": "platform",
      "description": "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.",
      "affectedConversions": [
        "admin-to-h3-weighted",
        "h3-to-platform-id"
      ],
      "detectionMethod": "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"
      ],
      "url": "https://etherdata.ai/blog/geo-interop-kb/edge-cases/asynchronous-boundary-updates"
    },
    {
      "id": "physical-presence",
      "name": "Physical presence",
      "category": "advertising",
      "description": "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.",
      "affectedConversions": [
        "point-to-h3"
      ],
      "detectionMethod": "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"
      ],
      "url": "https://etherdata.ai/blog/geo-interop-kb/edge-cases/physical-presence"
    },
    {
      "id": "recent-presence",
      "name": "Recent presence",
      "category": "advertising",
      "description": "'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.",
      "affectedConversions": [
        "point-to-h3"
      ],
      "detectionMethod": "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"
      ],
      "url": "https://etherdata.ai/blog/geo-interop-kb/edge-cases/recent-presence"
    },
    {
      "id": "home-location",
      "name": "Home location inference",
      "category": "advertising",
      "description": "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.",
      "affectedConversions": [
        "point-to-h3"
      ],
      "detectionMethod": "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"
      ],
      "url": "https://etherdata.ai/blog/geo-interop-kb/edge-cases/home-location"
    },
    {
      "id": "work-location",
      "name": "Work location inference",
      "category": "advertising",
      "description": "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.",
      "affectedConversions": [
        "point-to-h3"
      ],
      "detectionMethod": "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"
      ],
      "url": "https://etherdata.ai/blog/geo-interop-kb/edge-cases/work-location"
    },
    {
      "id": "gps-derived-location",
      "name": "GPS-derived location accuracy",
      "category": "advertising",
      "description": "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.",
      "affectedConversions": [
        "point-to-h3"
      ],
      "detectionMethod": "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)"
      ],
      "url": "https://etherdata.ai/blog/geo-interop-kb/edge-cases/gps-derived-location"
    },
    {
      "id": "location-interest",
      "name": "Location interest versus presence",
      "category": "advertising",
      "description": "'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.",
      "affectedConversions": [
        "point-to-h3",
        "point-radius-to-h3"
      ],
      "detectionMethod": "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"
      ],
      "url": "https://etherdata.ai/blog/geo-interop-kb/edge-cases/location-interest"
    },
    {
      "id": "destination-interest",
      "name": "Destination interest",
      "category": "advertising",
      "description": "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.",
      "affectedConversions": [
        "point-to-h3",
        "point-radius-to-h3"
      ],
      "detectionMethod": "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"
      ],
      "url": "https://etherdata.ai/blog/geo-interop-kb/edge-cases/destination-interest"
    },
    {
      "id": "publisher-declared-location",
      "name": "Publisher-declared location",
      "category": "advertising",
      "description": "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.",
      "affectedConversions": [
        "point-to-h3"
      ],
      "detectionMethod": "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"
      ],
      "url": "https://etherdata.ai/blog/geo-interop-kb/edge-cases/publisher-declared-location"
    },
    {
      "id": "cross-device-location",
      "name": "Cross-device location stitching",
      "category": "advertising",
      "description": "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.",
      "affectedConversions": [
        "point-to-h3"
      ],
      "detectionMethod": "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"
      ],
      "url": "https://etherdata.ai/blog/geo-interop-kb/edge-cases/cross-device-location"
    },
    {
      "id": "location-confidence",
      "name": "Location confidence scoring",
      "category": "advertising",
      "description": "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.",
      "affectedConversions": [
        "point-to-h3",
        "raster-to-h3"
      ],
      "detectionMethod": "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"
      ],
      "url": "https://etherdata.ai/blog/geo-interop-kb/edge-cases/location-confidence"
    },
    {
      "id": "lookback-window",
      "name": "Lookback window sensitivity",
      "category": "advertising",
      "description": "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.",
      "affectedConversions": [
        "point-to-h3",
        "point-radius-to-h3"
      ],
      "detectionMethod": "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"
      ],
      "url": "https://etherdata.ai/blog/geo-interop-kb/edge-cases/lookback-window"
    },
    {
      "id": "frequency-overlapping-targets",
      "name": "Frequency across overlapping targets",
      "category": "advertising",
      "description": "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.",
      "affectedConversions": [
        "point-radius-to-h3",
        "h3-to-platform-id",
        "cellsystem-to-cellsystem"
      ],
      "detectionMethod": "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"
      ],
      "url": "https://etherdata.ai/blog/geo-interop-kb/edge-cases/frequency-overlapping-targets"
    },
    {
      "id": "treatment-control-contamination",
      "name": "Treatment/control contamination",
      "category": "advertising",
      "description": "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.",
      "affectedConversions": [
        "cellsystem-to-cellsystem",
        "admin-to-h3-weighted"
      ],
      "detectionMethod": "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"
      ],
      "url": "https://etherdata.ai/blog/geo-interop-kb/edge-cases/treatment-control-contamination"
    },
    {
      "id": "excluded-area-leakage",
      "fixture": "include-exclude",
      "name": "Excluded-area leakage",
      "category": "advertising",
      "description": "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.",
      "affectedConversions": [
        "h3-to-platform-id",
        "point-radius-to-h3"
      ],
      "detectionMethod": "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"
      ],
      "url": "https://etherdata.ai/blog/geo-interop-kb/edge-cases/excluded-area-leakage"
    },
    {
      "id": "bidstream-truncation",
      "name": "Bidstream truncation",
      "category": "advertising",
      "description": "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.",
      "affectedConversions": [
        "point-to-h3",
        "raster-to-h3"
      ],
      "detectionMethod": "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"
      ],
      "url": "https://etherdata.ai/blog/geo-interop-kb/edge-cases/bidstream-truncation"
    },
    {
      "id": "small-cell-reidentification",
      "name": "Small-cell re-identification risk",
      "category": "privacy",
      "description": "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.",
      "affectedConversions": [
        "point-to-h3",
        "raster-to-h3"
      ],
      "detectionMethod": "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"
      ],
      "url": "https://etherdata.ai/blog/geo-interop-kb/edge-cases/small-cell-reidentification"
    },
    {
      "id": "k-anonymity",
      "name": "K-anonymity thresholds",
      "category": "privacy",
      "description": "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.",
      "affectedConversions": [
        "point-to-h3",
        "raster-to-h3",
        "admin-to-h3-weighted"
      ],
      "detectionMethod": "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"
      ],
      "url": "https://etherdata.ai/blog/geo-interop-kb/edge-cases/k-anonymity"
    },
    {
      "id": "differential-privacy",
      "name": "Differential privacy noise calibration",
      "category": "privacy",
      "description": "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.",
      "affectedConversions": [
        "point-to-h3",
        "raster-to-h3"
      ],
      "detectionMethod": "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"
      ],
      "url": "https://etherdata.ai/blog/geo-interop-kb/edge-cases/differential-privacy"
    },
    {
      "id": "temporal-leakage",
      "name": "Temporal leakage",
      "category": "privacy",
      "description": "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.",
      "affectedConversions": [
        "point-to-h3",
        "raster-to-h3"
      ],
      "detectionMethod": "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"
      ],
      "url": "https://etherdata.ai/blog/geo-interop-kb/edge-cases/temporal-leakage"
    },
    {
      "id": "household-level-targeting",
      "name": "Household-level targeting risk",
      "category": "privacy",
      "description": "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.",
      "affectedConversions": [
        "point-radius-to-h3",
        "polygon-to-h3-center",
        "h3-to-platform-id"
      ],
      "detectionMethod": "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"
      ],
      "url": "https://etherdata.ai/blog/geo-interop-kb/edge-cases/household-level-targeting"
    },
    {
      "id": "minimum-aggregation-window",
      "name": "Minimum aggregation window",
      "category": "privacy",
      "description": "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.",
      "affectedConversions": [
        "point-to-h3",
        "raster-to-h3"
      ],
      "detectionMethod": "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"
      ],
      "url": "https://etherdata.ai/blog/geo-interop-kb/edge-cases/minimum-aggregation-window"
    },
    {
      "id": "resolution-degradation",
      "name": "Resolution degradation policy",
      "category": "privacy",
      "description": "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.",
      "affectedConversions": [
        "point-to-h3",
        "raster-to-h3",
        "admin-to-h3-weighted"
      ],
      "detectionMethod": "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"
      ],
      "url": "https://etherdata.ai/blog/geo-interop-kb/edge-cases/resolution-degradation"
    },
    {
      "id": "regional-privacy-restrictions",
      "name": "Regional privacy restrictions",
      "category": "privacy",
      "description": "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.",
      "affectedConversions": [
        "point-to-h3",
        "point-radius-to-h3",
        "admin-to-h3-weighted"
      ],
      "detectionMethod": "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"
      ],
      "url": "https://etherdata.ai/blog/geo-interop-kb/edge-cases/regional-privacy-restrictions"
    },
    {
      "id": "administrative-boundary-changes",
      "name": "Administrative boundary changes",
      "category": "temporal",
      "description": "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.",
      "affectedConversions": [
        "admin-to-h3-maxoverlap",
        "admin-to-h3-weighted"
      ],
      "detectionMethod": "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"
      ],
      "url": "https://etherdata.ai/blog/geo-interop-kb/edge-cases/administrative-boundary-changes"
    },
    {
      "id": "temporary-event-zones",
      "name": "Temporary event zones",
      "category": "temporal",
      "description": "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.",
      "affectedConversions": [
        "polygon-to-h3-intersect",
        "point-radius-to-h3"
      ],
      "detectionMethod": "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"
      ],
      "url": "https://etherdata.ai/blog/geo-interop-kb/edge-cases/temporary-event-zones"
    },
    {
      "id": "weather-polygons",
      "name": "Weather polygons",
      "category": "temporal",
      "description": "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.",
      "affectedConversions": [
        "polygon-to-h3-intersect",
        "raster-to-h3"
      ],
      "detectionMethod": "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"
      ],
      "url": "https://etherdata.ai/blog/geo-interop-kb/edge-cases/weather-polygons"
    },
    {
      "id": "store-openings-closures",
      "name": "Store openings and closures",
      "category": "temporal",
      "description": "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.",
      "affectedConversions": [
        "point-radius-to-h3",
        "admin-to-h3-weighted"
      ],
      "detectionMethod": "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"
      ],
      "url": "https://etherdata.ai/blog/geo-interop-kb/edge-cases/store-openings-closures"
    },
    {
      "id": "time-dependent-audience",
      "name": "Time-dependent audience membership",
      "category": "temporal",
      "description": "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.",
      "affectedConversions": [
        "point-to-h3",
        "point-radius-to-h3"
      ],
      "detectionMethod": "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"
      ],
      "url": "https://etherdata.ai/blog/geo-interop-kb/edge-cases/time-dependent-audience"
    },
    {
      "id": "version-mismatch-execution-reporting",
      "name": "Version mismatch between execution and reporting",
      "category": "temporal",
      "description": "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.",
      "affectedConversions": [
        "cellsystem-to-cellsystem",
        "admin-to-h3-weighted",
        "h3-to-platform-id"
      ],
      "detectionMethod": "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"
      ],
      "url": "https://etherdata.ai/blog/geo-interop-kb/edge-cases/version-mismatch-execution-reporting"
    }
  ],
  "conversionMethods": [
    {
      "id": "polygon-to-h3-center",
      "name": "Polygon → H3 (center-contained)",
      "direction": "source_to_h3",
      "sourceGeometryTypes": [
        "polygon",
        "admin_country",
        "admin_state",
        "admin_county",
        "trade_area"
      ],
      "destinationGeometryTypes": [
        "h3_cell_set"
      ],
      "exactness": "approximate",
      "parameters": [
        {
          "name": "resolution",
          "type": "integer",
          "required": true,
          "description": "Target H3 resolution 0–15."
        }
      ],
      "algorithm": "Include a cell iff its center point lies inside the polygon (h3 polygonToCells).",
      "assumptions": [
        "EPSG:4326 input",
        "planar point-in-polygon on the sphere-projected ring"
      ],
      "outputs": [
        "h3_cell_set"
      ],
      "qualityMetrics": [
        "coverageRatio",
        "underreachRatio"
      ],
      "edgeCases": [
        "narrow-polygons",
        "tiny-polygons"
      ],
      "references": [],
      "page": "arbitrary-polygon-to-h3"
    },
    {
      "id": "polygon-to-h3-full",
      "name": "Polygon → H3 (fully-contained)",
      "direction": "source_to_h3",
      "sourceGeometryTypes": [
        "polygon",
        "parcel"
      ],
      "destinationGeometryTypes": [
        "h3_cell_set"
      ],
      "exactness": "conservative",
      "parameters": [
        {
          "name": "resolution",
          "type": "integer",
          "required": true,
          "description": "Target H3 resolution."
        }
      ],
      "algorithm": "Include a cell iff it is entirely inside the polygon (coverageFraction ≥ 1−ε).",
      "assumptions": [
        "EPSG:4326 input"
      ],
      "outputs": [
        "h3_cell_set"
      ],
      "qualityMetrics": [
        "underreachRatio",
        "uncoveredAreaM2"
      ],
      "edgeCases": [
        "narrow-polygons",
        "touching-only"
      ],
      "page": "arbitrary-polygon-to-h3"
    },
    {
      "id": "polygon-to-h3-intersect",
      "name": "Polygon → H3 (intersects)",
      "direction": "source_to_h3",
      "sourceGeometryTypes": [
        "polygon",
        "geofence",
        "trade_area"
      ],
      "destinationGeometryTypes": [
        "h3_cell_set"
      ],
      "exactness": "expansive",
      "parameters": [
        {
          "name": "resolution",
          "type": "integer",
          "required": true,
          "description": "Target H3 resolution."
        }
      ],
      "algorithm": "Include a cell iff it intersects the polygon (intersection area > 0).",
      "assumptions": [
        "EPSG:4326 input"
      ],
      "outputs": [
        "h3_cell_set"
      ],
      "qualityMetrics": [
        "coverageRatio",
        "overreachRatio",
        "jaccardSimilarity"
      ],
      "edgeCases": [
        "touching-only",
        "simplified-boundaries"
      ],
      "page": "arbitrary-polygon-to-h3"
    },
    {
      "id": "polygon-to-h3-threshold",
      "name": "Polygon → H3 (coverage threshold)",
      "direction": "source_to_h3",
      "sourceGeometryTypes": [
        "polygon",
        "trade_area"
      ],
      "destinationGeometryTypes": [
        "h3_cell_set"
      ],
      "exactness": "approximate",
      "parameters": [
        {
          "name": "resolution",
          "type": "integer",
          "required": true,
          "description": "Target H3 resolution."
        },
        {
          "name": "threshold",
          "type": "number",
          "required": true,
          "default": "0.5",
          "description": "Min intersection_area/cell_area to include."
        }
      ],
      "algorithm": "Include a cell iff intersection_area / cell_area ≥ threshold.",
      "assumptions": [
        "EPSG:4326 input"
      ],
      "outputs": [
        "h3_cell_set"
      ],
      "qualityMetrics": [
        "coverageRatio",
        "overreachRatio"
      ],
      "edgeCases": [
        "simplified-boundaries"
      ],
      "page": "arbitrary-polygon-to-h3"
    },
    {
      "id": "admin-to-h3-maxoverlap",
      "name": "Admin polygons → H3 (max-overlap partition)",
      "direction": "admin_to_h3",
      "sourceGeometryTypes": [
        "admin_county",
        "admin_state",
        "dma",
        "postal_code"
      ],
      "destinationGeometryTypes": [
        "h3_cell_set"
      ],
      "exactness": "approximate",
      "parameters": [
        {
          "name": "resolution",
          "type": "integer",
          "required": true,
          "description": "Target H3 resolution."
        }
      ],
      "algorithm": "Assign each cell to the region with the greatest intersection area (mutually exclusive).",
      "assumptions": [
        "regions are a partition or near-partition"
      ],
      "outputs": [
        "crosswalk"
      ],
      "qualityMetrics": [
        "coverageRatio"
      ],
      "edgeCases": [
        "duplicated-region-ids",
        "stale-boundaries"
      ],
      "page": "administrative-polygon-to-h3"
    },
    {
      "id": "admin-to-h3-weighted",
      "name": "Admin polygons → H3 (weighted crosswalk)",
      "direction": "admin_to_h3",
      "sourceGeometryTypes": [
        "admin_county",
        "census_geo",
        "dma",
        "postal_code"
      ],
      "destinationGeometryTypes": [
        "crosswalk"
      ],
      "exactness": "weighted",
      "parameters": [
        {
          "name": "resolution",
          "type": "integer",
          "required": true,
          "description": "Target H3 resolution."
        },
        {
          "name": "weight",
          "type": "string",
          "required": false,
          "default": "area",
          "description": "area | population | audience | inventory."
        }
      ],
      "algorithm": "Emit every (cell,region) overlap with intersection area and coverage fractions; apportion values by chosen weight.",
      "assumptions": [
        "weights sourced and versioned"
      ],
      "outputs": [
        "crosswalk"
      ],
      "qualityMetrics": [
        "coverageRatio",
        "jaccardSimilarity"
      ],
      "edgeCases": [
        "duplicated-region-ids"
      ],
      "page": "h3-to-administrative-crosswalk"
    },
    {
      "id": "point-to-h3",
      "name": "Point → H3",
      "direction": "source_to_h3",
      "sourceGeometryTypes": [
        "poi",
        "address",
        "device_ping"
      ],
      "destinationGeometryTypes": [
        "h3_cell_set"
      ],
      "exactness": "exact",
      "parameters": [
        {
          "name": "resolution",
          "type": "integer",
          "required": true,
          "description": "Target H3 resolution."
        }
      ],
      "algorithm": "latLngToCell(lat,lng,res). Exact for the point; the point's own uncertainty is separate.",
      "assumptions": [
        "EPSG:4326",
        "coordinate is correct (not axis-swapped)"
      ],
      "outputs": [
        "h3_cell_set"
      ],
      "qualityMetrics": [],
      "edgeCases": [
        "axis-order-reversal",
        "geocoding-uncertainty",
        "rounded-coordinates"
      ],
      "page": "point-to-h3"
    },
    {
      "id": "point-radius-to-h3",
      "name": "Point+radius → H3",
      "direction": "source_to_h3",
      "sourceGeometryTypes": [
        "point_radius",
        "device_ping"
      ],
      "destinationGeometryTypes": [
        "h3_cell_set"
      ],
      "exactness": "approximate",
      "parameters": [
        {
          "name": "resolution",
          "type": "integer",
          "required": true,
          "description": "Target H3 resolution."
        },
        {
          "name": "containment",
          "type": "string",
          "required": false,
          "default": "intersect",
          "description": "center | full | intersect | threshold."
        }
      ],
      "algorithm": "Buffer the point to a geodesic disk polygon, then polygon→H3 under the chosen containment rule.",
      "assumptions": [
        "geodesic radius",
        "EPSG:4326"
      ],
      "outputs": [
        "h3_cell_set"
      ],
      "qualityMetrics": [
        "coverageRatio",
        "overreachRatio"
      ],
      "edgeCases": [
        "minimum-radius"
      ],
      "page": "point-radius-to-h3"
    },
    {
      "id": "line-to-h3",
      "name": "Line/corridor → H3",
      "direction": "source_to_h3",
      "sourceGeometryTypes": [
        "line_road",
        "trajectory"
      ],
      "destinationGeometryTypes": [
        "h3_cell_set"
      ],
      "exactness": "approximate",
      "parameters": [
        {
          "name": "resolution",
          "type": "integer",
          "required": true,
          "description": "Target H3 resolution."
        },
        {
          "name": "buffer",
          "type": "number",
          "required": false,
          "description": "Corridor half-width in meters (0 = pure intersection)."
        }
      ],
      "algorithm": "Intersect cells along the line, or buffer to a corridor polygon then polygon→H3. Preserve order for trajectories.",
      "assumptions": [
        "EPSG:4326",
        "GPS noise handled upstream"
      ],
      "outputs": [
        "h3_cell_set",
        "ordered_h3_sequence"
      ],
      "qualityMetrics": [
        "coverageRatio"
      ],
      "edgeCases": [
        "gps-noise",
        "boundary-oscillation"
      ],
      "page": "line-and-corridor-to-h3"
    },
    {
      "id": "raster-to-h3",
      "name": "Raster → H3",
      "direction": "raster_to_h3",
      "sourceGeometryTypes": [
        "raster"
      ],
      "destinationGeometryTypes": [
        "h3_cell_set"
      ],
      "exactness": "approximate",
      "parameters": [
        {
          "name": "resolution",
          "type": "integer",
          "required": true,
          "description": "Target H3 resolution."
        },
        {
          "name": "aggregation",
          "type": "string",
          "required": true,
          "description": "mean | median | min | max | sum | majority | area_weighted."
        }
      ],
      "algorithm": "Aggregate pixel values within each cell by the chosen statistic; area-weighted for partial pixels.",
      "assumptions": [
        "raster reprojected to a common CRS",
        "nodata handled"
      ],
      "outputs": [
        "h3_cell_set_with_values"
      ],
      "qualityMetrics": [],
      "edgeCases": [
        "resolution-mismatch",
        "nodata-values",
        "coastal-pixels"
      ],
      "page": "raster-to-h3"
    },
    {
      "id": "h3-to-polygon",
      "name": "H3 → exact polygon",
      "direction": "h3_to_execution",
      "sourceGeometryTypes": [
        "h3_cell_set"
      ],
      "destinationGeometryTypes": [
        "polygon",
        "multipolygon"
      ],
      "exactness": "exact",
      "parameters": [
        {
          "name": "densify",
          "type": "integer",
          "required": false,
          "description": "Great-circle samples per edge for antimeridian-safe output."
        }
      ],
      "algorithm": "cellToBoundary / cellsToMultiPolygon; close rings, order coordinates, densify edges as needed.",
      "assumptions": [
        "spherical cell edges"
      ],
      "outputs": [
        "polygon",
        "multipolygon"
      ],
      "qualityMetrics": [],
      "edgeCases": [
        "antimeridian",
        "pentagons",
        "face-crossing-cells"
      ],
      "page": "h3-to-exact-polygon"
    },
    {
      "id": "h3-to-inscribed-circle",
      "name": "H3 → inscribed circle",
      "direction": "h3_to_execution",
      "sourceGeometryTypes": [
        "h3_cell_set"
      ],
      "destinationGeometryTypes": [
        "point_radius"
      ],
      "exactness": "conservative",
      "parameters": [
        {
          "name": "edgeSamples",
          "type": "integer",
          "required": false,
          "default": "64",
          "description": "Great-circle samples per edge."
        }
      ],
      "algorithm": "center = cell center; radius = min geodesic distance from center to a densified boundary (NOT nearest vertex), minus safety margin.",
      "assumptions": [
        "spherical model",
        "haversine distance"
      ],
      "outputs": [
        "point_radius"
      ],
      "qualityMetrics": [
        "underreachRatio",
        "uncoveredAreaM2"
      ],
      "edgeCases": [
        "pentagons",
        "face-crossing-cells"
      ],
      "page": "h3-to-inscribed-circle"
    },
    {
      "id": "h3-to-circumscribed-circle",
      "name": "H3 → circumscribed circle",
      "direction": "h3_to_execution",
      "sourceGeometryTypes": [
        "h3_cell_set"
      ],
      "destinationGeometryTypes": [
        "point_radius"
      ],
      "exactness": "expansive",
      "parameters": [
        {
          "name": "edgeSamples",
          "type": "integer",
          "required": false,
          "default": "64",
          "description": "Great-circle samples per edge."
        }
      ],
      "algorithm": "center = cell center; radius = max geodesic distance from center to boundary, plus safety margin.",
      "assumptions": [
        "spherical model",
        "haversine distance"
      ],
      "outputs": [
        "point_radius"
      ],
      "qualityMetrics": [
        "overreachRatio",
        "duplicateEligibilityAreaM2"
      ],
      "edgeCases": [
        "pentagons"
      ],
      "page": "h3-to-circumscribed-circle"
    },
    {
      "id": "h3-to-equal-area-circle",
      "name": "H3 → equal-area circle",
      "direction": "h3_to_execution",
      "sourceGeometryTypes": [
        "h3_cell_set"
      ],
      "destinationGeometryTypes": [
        "point_radius"
      ],
      "exactness": "approximate",
      "parameters": [],
      "algorithm": "radius = sqrt(cellArea/π). Same area as the cell; neither contains nor is contained by it.",
      "assumptions": [
        "h3 cellArea"
      ],
      "outputs": [
        "point_radius"
      ],
      "qualityMetrics": [
        "coverageRatio",
        "overreachRatio"
      ],
      "edgeCases": [],
      "page": "h3-to-equal-area-circle"
    },
    {
      "id": "h3-to-optimized-cover",
      "name": "H3 cell set → optimized circle cover",
      "direction": "h3_to_execution",
      "sourceGeometryTypes": [
        "h3_cell_set"
      ],
      "destinationGeometryTypes": [
        "point_radius"
      ],
      "exactness": "approximate",
      "parameters": [
        {
          "name": "maxOverreach",
          "type": "number",
          "required": false,
          "description": "Reject candidate circles whose local overreach exceeds this."
        },
        {
          "name": "minCoverage",
          "type": "number",
          "required": false,
          "default": "1",
          "description": "Stop once this fraction of cells is covered."
        },
        {
          "name": "maxK",
          "type": "integer",
          "required": false,
          "default": "3",
          "description": "Neighbourhood radius (grid rings) for disk growth."
        }
      ],
      "algorithm": "Greedy weighted set-cover over disks grown from cell centres; pick the disk maximizing newly-covered cells per unit overreach until coverage target met. Experimental, not optimal.",
      "assumptions": [
        "spherical model",
        "coverage defined on cell centres",
        "hundreds–thousands of cells"
      ],
      "outputs": [
        "point_radius",
        "coverage_report"
      ],
      "qualityMetrics": [
        "overreachRatio",
        "duplicateEligibilityArea",
        "coverageRatio"
      ],
      "edgeCases": [
        "minimum-radius",
        "radius-increments",
        "optimized-targeting-expansion"
      ],
      "page": "optimized-circle-cover"
    },
    {
      "id": "h3-to-platform-id",
      "name": "H3 → platform-native IDs",
      "direction": "h3_to_admin",
      "sourceGeometryTypes": [
        "h3_cell_set"
      ],
      "destinationGeometryTypes": [
        "platform_geo_id"
      ],
      "exactness": "approximate",
      "parameters": [
        {
          "name": "crosswalkVintage",
          "type": "string",
          "required": true,
          "description": "Version of the platform boundary set."
        }
      ],
      "algorithm": "H3 → normalized polygon union → versioned platform crosswalk → native IDs. Record unmatched/partial cells.",
      "assumptions": [
        "a maintained, versioned platform boundary crosswalk exists"
      ],
      "outputs": [
        "platform_geo_id",
        "unmatched_cells"
      ],
      "qualityMetrics": [
        "coverageRatio"
      ],
      "edgeCases": [
        "platform-native-ids-only",
        "asynchronous-boundary-updates"
      ],
      "page": "h3-to-platform-native-geography"
    },
    {
      "id": "cellsystem-to-cellsystem",
      "name": "Cell system → cell system",
      "direction": "cell_system_to_cell_system",
      "sourceGeometryTypes": [
        "h3_cell_set"
      ],
      "destinationGeometryTypes": [
        "h3_cell_set"
      ],
      "exactness": "approximate",
      "parameters": [
        {
          "name": "via",
          "type": "string",
          "required": false,
          "default": "polygon",
          "description": "Convert via polygon union, then fill the target system."
        }
      ],
      "algorithm": "Dissolve source cells to a polygon, then fill in the target system's cells under a containment rule. There is no exact cell-to-cell mapping across systems.",
      "assumptions": [
        "both systems reference EPSG:4326"
      ],
      "outputs": [
        "target_cell_set"
      ],
      "qualityMetrics": [
        "jaccardSimilarity"
      ],
      "edgeCases": [],
      "page": "cell-system-comparison"
    }
  ],
  "executionFormats": [
    {
      "id": "exact_polygon",
      "name": "Exact polygon / MultiPolygon",
      "produces": "polygon",
      "fromMethods": [
        "h3-to-polygon"
      ],
      "approximation": "exact",
      "description": "The dissolved cell-set boundary as GeoJSON rings. Lossless w.r.t. the cell set, subject to ring closure, coordinate ordering, and antimeridian handling.",
      "platformSupport": "Polygon-capable platforms only; often vertex-count capped.",
      "errorIntroduced": "None beyond the cell discretisation itself, unless simplified to meet a vertex cap.",
      "relatedEdgeCases": [
        "antimeridian",
        "pentagons",
        "unsupported-polygons",
        "simplified-boundaries"
      ]
    },
    {
      "id": "inscribed_circle",
      "name": "Inscribed circle (point + radius)",
      "produces": "point_radius",
      "fromMethods": [
        "h3-to-inscribed-circle"
      ],
      "approximation": "conservative",
      "description": "Largest disk centred on the cell centre that fits inside the cell (⊆). One per cell.",
      "platformSupport": "Any point+radius platform.",
      "errorIntroduced": "Underreach — uncovered cell corners (~9% of a regular hexagon's area).",
      "relatedEdgeCases": [
        "minimum-radius",
        "radius-increments",
        "pentagons"
      ]
    },
    {
      "id": "circumscribed_circle",
      "name": "Circumscribed circle (point + radius)",
      "produces": "point_radius",
      "fromMethods": [
        "h3-to-circumscribed-circle"
      ],
      "approximation": "expansive",
      "description": "Smallest disk containing the cell (⊇). One per cell.",
      "platformSupport": "Any point+radius platform.",
      "errorIntroduced": "Overreach and overlap between neighbours → duplicate eligibility.",
      "relatedEdgeCases": [
        "minimum-radius",
        "radius-increments",
        "undocumented-deduplication"
      ]
    },
    {
      "id": "equal_area_circle",
      "name": "Equal-area circle (point + radius)",
      "produces": "point_radius",
      "fromMethods": [
        "h3-to-equal-area-circle"
      ],
      "approximation": "approximate",
      "description": "Disk with r = sqrt(cellArea/π). Same area as the cell; neither contains nor is contained by it.",
      "platformSupport": "Planning / reach estimation, not exact execution.",
      "errorIntroduced": "Simultaneous gaps and overlaps.",
      "relatedEdgeCases": [
        "minimum-radius"
      ]
    },
    {
      "id": "circle_cover",
      "name": "Optimized circle cover",
      "produces": "point_radius",
      "fromMethods": [
        "h3-to-optimized-cover"
      ],
      "approximation": "approximate",
      "description": "Fewer, larger circles covering the target set under a bounded overreach. Experimental heuristic.",
      "platformSupport": "Point+radius platforms with tight target-count caps.",
      "errorIntroduced": "Bounded overreach; coverage defined on cell centres.",
      "relatedEdgeCases": [
        "optimized-targeting-expansion",
        "minimum-radius",
        "radius-increments"
      ]
    },
    {
      "id": "admin_id_set",
      "name": "Administrative ID set",
      "produces": "platform_geo_id",
      "fromMethods": [
        "admin-to-h3-weighted",
        "h3-to-platform-id"
      ],
      "approximation": "approximate",
      "description": "Cells crosswalked to admin units (FIPS/ZCTA/DMA) and targeted by ID. Coarser than the cells; reporting also at the unit.",
      "platformSupport": "Admin-ID and polygon platforms.",
      "errorIntroduced": "Boundary vintage error; sub-unit precision loss; reported ≠ executed.",
      "relatedEdgeCases": [
        "stale-boundaries",
        "platform-native-ids-only",
        "reporting-coarser-level"
      ]
    },
    {
      "id": "platform_native_ids",
      "name": "Platform-native geo IDs",
      "produces": "platform_geo_id",
      "fromMethods": [
        "h3-to-platform-id"
      ],
      "approximation": "approximate",
      "description": "Cells mapped via a versioned platform boundary crosswalk to opaque platform IDs. An identifier is not a geometry.",
      "platformSupport": "ID-only platforms.",
      "errorIntroduced": "Unmatched/partial cells; namespace and vintage ambiguity.",
      "relatedEdgeCases": [
        "platform-native-ids-only",
        "asynchronous-boundary-updates",
        "unmatched-cells"
      ]
    },
    {
      "id": "native_cell_set",
      "name": "Native H3 cell set",
      "produces": "cell_set",
      "fromMethods": [],
      "approximation": "exact",
      "description": "Platforms that ingest H3 indices directly execute the cells as-is (may coerce mixed resolutions).",
      "platformSupport": "Cell-native platforms.",
      "errorIntroduced": "None, unless the platform coerces to a single resolution.",
      "relatedEdgeCases": [
        "mixed-resolutions",
        "resolution-coercion"
      ]
    }
  ],
  "cellSystems": [
    {
      "id": "h3",
      "name": "H3",
      "hierarchyModel": "Aperture-7 hierarchy; each parent has 7 logical children (approx). 16 resolutions.",
      "resolutionRange": "0–15",
      "equalArea": "approximate",
      "cellShapes": [
        "hexagon",
        "pentagon"
      ],
      "globalCoverage": true,
      "indexRepresentation": "64-bit H3 index, hex string",
      "pointIndexing": true,
      "polygonFill": true,
      "parentChildBehavior": "Logical parent/child via index arithmetic; NOT exact geometric containment (children can spill past the parent boundary).",
      "boundaryExtraction": true,
      "centroidExtraction": true,
      "compactionSupport": true,
      "geometricCaveats": [
        "12 pentagons per resolution (icosahedron vertices) break the 6-neighbour assumption.",
        "Cells crossing icosahedron faces are distorted; area varies ~2x across the globe.",
        "Aperture-7 means a parent is not exactly 7 children by area."
      ],
      "references": [
        {
          "title": "H3 Documentation",
          "organization": "Uber",
          "url": "https://h3geo.org/",
          "lastVerifiedDate": "2026-07-22"
        }
      ]
    },
    {
      "id": "s2",
      "name": "S2",
      "hierarchyModel": "Quad hierarchy on the 6 faces of a cube projected to the sphere; each parent has exactly 4 children. 31 levels.",
      "resolutionRange": "0–30 (levels)",
      "equalArea": false,
      "cellShapes": [
        "quadrilateral"
      ],
      "globalCoverage": true,
      "indexRepresentation": "64-bit cell id (Hilbert curve position)",
      "pointIndexing": true,
      "polygonFill": true,
      "parentChildBehavior": "Exact geometric containment: 4 children exactly tile the parent.",
      "boundaryExtraction": true,
      "centroidExtraction": true,
      "compactionSupport": true,
      "geometricCaveats": [
        "Cells are NOT equal area; area varies with position on the cube face.",
        "Quadrilateral cells have 4 (edge) or more (corner) neighbours."
      ],
      "references": [
        {
          "title": "S2 Geometry",
          "organization": "Google",
          "url": "https://s2geometry.io/",
          "lastVerifiedDate": "2026-07-22"
        }
      ]
    },
    {
      "id": "geohash",
      "name": "Geohash",
      "hierarchyModel": "Base-32 string; each added character subdivides a lat/lng rectangle into 32 (8x4 or 4x8 alternating).",
      "resolutionRange": "1–12 (characters)",
      "equalArea": false,
      "cellShapes": [
        "rectangle"
      ],
      "globalCoverage": true,
      "indexRepresentation": "Base-32 string (prefix = ancestor)",
      "pointIndexing": true,
      "polygonFill": false,
      "parentChildBehavior": "Prefix hierarchy: a shorter geohash exactly contains all longer ones sharing its prefix.",
      "boundaryExtraction": true,
      "centroidExtraction": true,
      "compactionSupport": false,
      "geometricCaveats": [
        "Rectangles in lat/lng degrees — highly non-equal-area; cells shrink toward the poles.",
        "Adjacent locations can have very different prefixes (edge/antimeridian discontinuity).",
        "Aspect ratio alternates between odd/even lengths."
      ],
      "references": [
        {
          "title": "Geohash",
          "organization": "Wikipedia",
          "url": "https://en.wikipedia.org/wiki/Geohash",
          "lastVerifiedDate": "2026-07-22"
        }
      ]
    }
  ],
  "geometryTypes": [
    {
      "id": "admin_country",
      "name": "Country",
      "category": "administrative",
      "description": "National boundary, typically from ISO 3166 / GADM / Natural Earth.",
      "examples": [
        "United States",
        "France"
      ],
      "sourceFormats": [
        "GeoJSON",
        "Shapefile",
        "ISO code"
      ],
      "requiredMetadata": [
        "boundary_source",
        "vintage",
        "crs"
      ],
      "conversionOptions": [
        "admin_to_h3",
        "center_fill",
        "intersect_fill"
      ],
      "commonRisks": [
        "disputed-territories",
        "offshore-islands",
        "boundary-vintage"
      ],
      "relatedEdgeCases": [
        "disputed-territories",
        "antimeridian",
        "offshore-islands"
      ]
    },
    {
      "id": "admin_state",
      "name": "State / Province",
      "category": "administrative",
      "description": "First-level subdivision (US state, CA province, EU NUTS-1/2).",
      "examples": [
        "New York",
        "Ontario"
      ],
      "sourceFormats": [
        "GeoJSON",
        "Shapefile",
        "FIPS/ISO code"
      ],
      "requiredMetadata": [
        "boundary_source",
        "vintage",
        "crs"
      ],
      "conversionOptions": [
        "admin_to_h3",
        "max_overlap",
        "weighted_crosswalk"
      ],
      "commonRisks": [
        "boundary-vintage",
        "duplicated-region-ids"
      ],
      "relatedEdgeCases": [
        "stale-boundaries",
        "duplicated-region-ids"
      ]
    },
    {
      "id": "admin_county",
      "name": "County",
      "category": "administrative",
      "description": "Second-level subdivision; US counties keyed by 5-digit FIPS.",
      "examples": [
        "Cook County (17031)",
        "Los Angeles County (06037)"
      ],
      "sourceFormats": [
        "GeoJSON",
        "Shapefile",
        "FIPS code"
      ],
      "requiredMetadata": [
        "boundary_source",
        "vintage",
        "crs"
      ],
      "conversionOptions": [
        "admin_to_h3",
        "weighted_crosswalk"
      ],
      "commonRisks": [
        "fips-leading-zero-loss",
        "boundary-vintage"
      ],
      "relatedEdgeCases": [
        "duplicated-region-ids",
        "stale-boundaries"
      ]
    },
    {
      "id": "postal_code",
      "name": "Postal / ZIP code",
      "category": "administrative",
      "description": "Postal delivery area. US ZIP codes are point/route sets, not true polygons; ZCTAs are the census polygon approximation.",
      "examples": [
        "10001",
        "SW1A 1AA"
      ],
      "sourceFormats": [
        "GeoJSON",
        "Shapefile",
        "code list"
      ],
      "requiredMetadata": [
        "boundary_source",
        "vintage",
        "polygon vs point set"
      ],
      "conversionOptions": [
        "admin_to_h3",
        "weighted_crosswalk"
      ],
      "commonRisks": [
        "zip-is-not-a-polygon",
        "boundary-vintage",
        "postal-changes"
      ],
      "relatedEdgeCases": [
        "stale-boundaries",
        "postal-boundary-changes"
      ]
    },
    {
      "id": "census_geo",
      "name": "Census geography",
      "category": "administrative",
      "description": "Statistical units (tract, block group, block); the analytic backbone of demographics.",
      "examples": [
        "Census tract 36061007100"
      ],
      "sourceFormats": [
        "GeoJSON",
        "Shapefile",
        "TIGER"
      ],
      "requiredMetadata": [
        "vintage",
        "geoid",
        "crs"
      ],
      "conversionOptions": [
        "admin_to_h3",
        "weighted_crosswalk",
        "population_weighted"
      ],
      "commonRisks": [
        "vintage-mismatch",
        "geoid-leading-zero"
      ],
      "relatedEdgeCases": [
        "stale-boundaries",
        "duplicated-region-ids"
      ]
    },
    {
      "id": "dma",
      "name": "DMA / Media market",
      "category": "administrative",
      "description": "Designated Market Area — a proprietary media planning geography (Nielsen).",
      "examples": [
        "New York DMA (501)"
      ],
      "sourceFormats": [
        "Shapefile",
        "DMA id"
      ],
      "requiredMetadata": [
        "vendor",
        "vintage",
        "license"
      ],
      "conversionOptions": [
        "admin_to_h3",
        "weighted_crosswalk"
      ],
      "commonRisks": [
        "licensing",
        "dma-redefinition"
      ],
      "relatedEdgeCases": [
        "dma-changes",
        "stale-boundaries"
      ]
    },
    {
      "id": "trade_area",
      "name": "Trade area / catchment",
      "category": "polygon",
      "description": "Modelled area a store draws customers from (drive-time or gravity model).",
      "examples": [
        "10-minute drive-time polygon",
        "70% customer catchment"
      ],
      "sourceFormats": [
        "GeoJSON",
        "isochrone service"
      ],
      "requiredMetadata": [
        "model",
        "generated_at",
        "crs"
      ],
      "conversionOptions": [
        "source_to_h3",
        "threshold_fill",
        "weighted_crosswalk"
      ],
      "commonRisks": [
        "model-drift",
        "self-intersection"
      ],
      "relatedEdgeCases": [
        "self-intersections",
        "simplified-boundaries"
      ]
    },
    {
      "id": "geofence",
      "name": "Geofence",
      "category": "polygon",
      "description": "Operator-drawn polygon for presence targeting or measurement.",
      "examples": [
        "Stadium footprint",
        "Mall parcel"
      ],
      "sourceFormats": [
        "GeoJSON",
        "KML"
      ],
      "requiredMetadata": [
        "author",
        "purpose",
        "crs"
      ],
      "conversionOptions": [
        "source_to_h3",
        "intersect_fill",
        "center_fill"
      ],
      "commonRisks": [
        "narrow-slivers",
        "tiny-polygons"
      ],
      "relatedEdgeCases": [
        "narrow-polygons",
        "tiny-polygons",
        "touching-only"
      ]
    },
    {
      "id": "parcel",
      "name": "Parcel",
      "category": "polygon",
      "description": "Legal land parcel boundary from an assessor / cadastre.",
      "examples": [
        "APN 1234-567-890"
      ],
      "sourceFormats": [
        "Shapefile",
        "GeoJSON"
      ],
      "requiredMetadata": [
        "assessor",
        "vintage",
        "crs"
      ],
      "conversionOptions": [
        "source_to_h3",
        "full_fill"
      ],
      "commonRisks": [
        "holes",
        "multipart"
      ],
      "relatedEdgeCases": [
        "holes",
        "multipart-geometries"
      ]
    },
    {
      "id": "poi",
      "name": "POI / Store point",
      "category": "point",
      "description": "A single lon/lat for a place; a point references a place, not its footprint.",
      "examples": [
        "Store #4821 (40.7128, -74.0060)"
      ],
      "sourceFormats": [
        "lat/lng",
        "GeoJSON Point"
      ],
      "requiredMetadata": [
        "crs",
        "source",
        "timestamp"
      ],
      "conversionOptions": [
        "point_to_cell",
        "buffer_to_radius"
      ],
      "commonRisks": [
        "stale-poi",
        "geocoding-uncertainty",
        "axis-swap"
      ],
      "relatedEdgeCases": [
        "geocoding-uncertainty",
        "axis-order-reversal",
        "zero-island"
      ]
    },
    {
      "id": "address",
      "name": "Address",
      "category": "point",
      "description": "A postal address that must be geocoded to a coordinate before conversion.",
      "examples": [
        "350 5th Ave, New York, NY 10118"
      ],
      "sourceFormats": [
        "text"
      ],
      "requiredMetadata": [
        "geocoder",
        "match_confidence",
        "crs"
      ],
      "conversionOptions": [
        "geocode_then_point_to_cell"
      ],
      "commonRisks": [
        "geocoding-uncertainty",
        "rooftop-vs-centroid"
      ],
      "relatedEdgeCases": [
        "geocoding-uncertainty"
      ]
    },
    {
      "id": "device_ping",
      "name": "Device ping / impression",
      "category": "point",
      "description": "A device coordinate from bidstream or SDK, with an accuracy radius.",
      "examples": [
        "bidstream lat/lng + hacc=35m"
      ],
      "sourceFormats": [
        "lat/lng + accuracy"
      ],
      "requiredMetadata": [
        "accuracy_radius",
        "source",
        "timestamp",
        "consent"
      ],
      "conversionOptions": [
        "point_to_cell",
        "accuracy_disk_to_h3"
      ],
      "commonRisks": [
        "ip-derived-precision",
        "rounded-coordinates",
        "consent-reduction"
      ],
      "relatedEdgeCases": [
        "rounded-coordinates",
        "ip-derived-location",
        "consent-precision"
      ]
    },
    {
      "id": "point_radius",
      "name": "Point + radius",
      "category": "point_radius",
      "description": "A center coordinate with a geodesic radius — the native execution unit of many DSPs.",
      "examples": [
        "(40.71,-74.00) r=3km"
      ],
      "sourceFormats": [
        "lat/lng + radius"
      ],
      "requiredMetadata": [
        "radius_units",
        "geodesic_or_planar"
      ],
      "conversionOptions": [
        "point_radius_to_h3",
        "h3_to_point_radius"
      ],
      "commonRisks": [
        "planar-vs-geodesic",
        "min-radius-floor"
      ],
      "relatedEdgeCases": [
        "minimum-radius",
        "radius-increments"
      ]
    },
    {
      "id": "line_road",
      "name": "Road / transit line",
      "category": "line",
      "description": "A polyline: road centerline, transit route, river.",
      "examples": [
        "I-95 segment",
        "M line"
      ],
      "sourceFormats": [
        "GeoJSON LineString",
        "Shapefile"
      ],
      "requiredMetadata": [
        "crs",
        "direction"
      ],
      "conversionOptions": [
        "line_intersect_h3",
        "buffered_corridor_to_h3"
      ],
      "commonRisks": [
        "gps-noise",
        "boundary-oscillation"
      ],
      "relatedEdgeCases": [
        "gps-noise",
        "boundary-oscillation"
      ]
    },
    {
      "id": "trajectory",
      "name": "Device trajectory",
      "category": "trajectory",
      "description": "An ordered, timestamped sequence of positions (a journey).",
      "examples": [
        "home→highway→store sequence"
      ],
      "sourceFormats": [
        "ordered lat/lng+t"
      ],
      "requiredMetadata": [
        "timestamps",
        "sampling_rate",
        "consent"
      ],
      "conversionOptions": [
        "trajectory_to_ordered_h3",
        "dwell_extraction"
      ],
      "commonRisks": [
        "gps-noise",
        "trajectory-exposure"
      ],
      "relatedEdgeCases": [
        "gps-noise",
        "device-trajectory-exposure",
        "boundary-oscillation"
      ]
    },
    {
      "id": "raster",
      "name": "Raster surface",
      "category": "raster",
      "description": "A gridded field: population, elevation, weather, pollution, signal strength.",
      "examples": [
        "WorldPop population raster",
        "flood depth grid"
      ],
      "sourceFormats": [
        "GeoTIFF",
        "NetCDF"
      ],
      "requiredMetadata": [
        "crs",
        "resolution",
        "nodata_value",
        "band_semantics"
      ],
      "conversionOptions": [
        "raster_to_h3_mean",
        "raster_to_h3_majority",
        "area_weighted"
      ],
      "commonRisks": [
        "resolution-mismatch",
        "nodata",
        "false-precision"
      ],
      "relatedEdgeCases": [
        "resolution-mismatch",
        "nodata-values",
        "coastal-pixels"
      ]
    },
    {
      "id": "bounding_box",
      "name": "Bounding box",
      "category": "bounding_box",
      "description": "A [west,south,east,north] envelope; a map viewport or query bounds.",
      "examples": [
        "[-74.1,40.6,-73.9,40.8]"
      ],
      "sourceFormats": [
        "bbox array"
      ],
      "requiredMetadata": [
        "crs",
        "screen vs geographic"
      ],
      "conversionOptions": [
        "bbox_to_polygon",
        "bbox_to_h3"
      ],
      "commonRisks": [
        "antimeridian",
        "west-east-order",
        "rotated-box"
      ],
      "relatedEdgeCases": [
        "antimeridian",
        "west-east-ordering",
        "screen-vs-geographic"
      ]
    },
    {
      "id": "platform_geo_id",
      "name": "Platform-native geo ID",
      "category": "platform_identifier",
      "description": "An opaque or standard identifier (postal id, FIPS, ISO, DMA id, publisher market id). References a geometry via a versioned crosswalk; it is NOT a geometry.",
      "examples": [
        "FIPS 36061",
        "DMA 501",
        "publisher market 'NYC-metro'"
      ],
      "sourceFormats": [
        "id string"
      ],
      "requiredMetadata": [
        "id_namespace",
        "boundary_vintage",
        "crosswalk_source"
      ],
      "conversionOptions": [
        "id_to_boundary",
        "h3_to_platform_id"
      ],
      "commonRisks": [
        "namespace-ambiguity",
        "boundary-vintage",
        "unmatched-ids"
      ],
      "relatedEdgeCases": [
        "platform-native-ids-only",
        "asynchronous-boundary-updates"
      ]
    },
    {
      "id": "h3_cell_set",
      "name": "H3 cell set",
      "category": "cell_set",
      "description": "A set of H3 indices, possibly mixed-resolution/compacted; the canonical interchange form in this KB.",
      "examples": [
        "[8a2a1072b59ffff, ...]",
        "compacted mixed-res set"
      ],
      "sourceFormats": [
        "h3 index list"
      ],
      "requiredMetadata": [
        "resolution",
        "compacted",
        "containment_mode"
      ],
      "conversionOptions": [
        "h3_to_execution",
        "h3_to_admin",
        "compact",
        "uncompact"
      ],
      "commonRisks": [
        "parent-child-duplicate",
        "mixed-resolution"
      ],
      "relatedEdgeCases": [
        "mixed-resolutions",
        "parent-child-duplicates",
        "pentagons"
      ]
    },
    {
      "id": "multipoint_audience",
      "name": "Multipoint / point cloud",
      "category": "multipoint",
      "description": "A collection of observations: bid requests, visits, conversions, sensor events.",
      "examples": [
        "1M conversion pings",
        "store-visit cloud"
      ],
      "sourceFormats": [
        "array of points",
        "GeoParquet"
      ],
      "requiredMetadata": [
        "crs",
        "timestamp",
        "dedup_key"
      ],
      "conversionOptions": [
        "aggregate_to_h3",
        "density_surface"
      ],
      "commonRisks": [
        "duplicate-observations",
        "privacy-suppression"
      ],
      "relatedEdgeCases": [
        "duplicate-observations",
        "sparse-audience-suppression"
      ]
    }
  ],
  "conversionProfiles": [
    {
      "id": "partition_stable",
      "name": "Partition (stable)",
      "intent": "Mutually-exclusive administrative partitions where every location belongs to exactly one region.",
      "containmentMode": "center",
      "resolutionPolicy": "Uniform resolution across the partition.",
      "weighting": "none",
      "guarantees": [
        "Deterministic single assignment per cell (center containment).",
        "No double counting."
      ],
      "tradeoffs": [
        "Boundary undercoverage: cells whose center sits just outside a region are dropped.",
        "Narrow regions thinner than a cell may receive zero cells."
      ],
      "recommendedMetrics": [
        "coverageRatio",
        "underreachRatio"
      ]
    },
    {
      "id": "coverage_complete",
      "name": "Coverage (complete)",
      "intent": "Delivery/serviceable-area coverage where missing ground is worse than spilling over.",
      "containmentMode": "intersect",
      "resolutionPolicy": "Resolution fine enough that boundary overreach is acceptable.",
      "weighting": "none",
      "guarantees": [
        "Every point of the source polygon is covered by at least one cell."
      ],
      "tradeoffs": [
        "Boundary overreach and overlap with neighbouring regions."
      ],
      "recommendedMetrics": [
        "coverageRatio",
        "overreachRatio",
        "jaccardSimilarity"
      ]
    },
    {
      "id": "measurement_weighted",
      "name": "Measurement (weighted)",
      "intent": "Reporting and analytics that apportion values across many-to-many cell/region relationships.",
      "containmentMode": "intersect",
      "resolutionPolicy": "Resolution matched to measurement grain; keep the weighted table.",
      "weighting": "area / population / audience / inventory",
      "guarantees": [
        "Full many-to-many overlap table with area and coverage fractions retained."
      ],
      "tradeoffs": [
        "More storage; apportionment weights must be sourced and versioned."
      ],
      "recommendedMetrics": [
        "coverageRatio",
        "jaccardSimilarity"
      ]
    },
    {
      "id": "experiment_conservative",
      "name": "Experiment (conservative)",
      "intent": "Geo experiments that must minimise treatment/control contamination.",
      "containmentMode": "full",
      "circleMode": "inscribed",
      "resolutionPolicy": "Coarser cells or inscribed circles to create buffered, non-touching units.",
      "weighting": "none",
      "guarantees": [
        "Executed geometry ⊆ intended unit (no spill into neighbours)."
      ],
      "tradeoffs": [
        "Under-reach: boundary area and cell corners are intentionally unserved."
      ],
      "recommendedMetrics": [
        "underreachRatio",
        "uncoveredAreaM2"
      ]
    },
    {
      "id": "proximity_full_reach",
      "name": "Proximity (full reach)",
      "intent": "Maximise reach around a set of cells/points; overlap is acceptable and reported.",
      "circleMode": "circumscribed",
      "resolutionPolicy": "Outer circles per cell.",
      "weighting": "none",
      "guarantees": [
        "Cell ⊆ circle (complete coverage)."
      ],
      "tradeoffs": [
        "Neighbouring circles overlap → duplicate eligibility; report it."
      ],
      "recommendedMetrics": [
        "overreachRatio",
        "duplicateEligibilityAreaM2"
      ]
    },
    {
      "id": "platform_limit_optimized",
      "name": "Platform-limit optimized",
      "intent": "Fit a target set within a platform's target-count and minimum-radius limits.",
      "containmentMode": "intersect",
      "resolutionPolicy": "Compact + coarsen until under the target cap and above the min radius.",
      "circleMode": "circumscribed",
      "weighting": "none",
      "guarantees": [
        "Result respects maxTargets and minRadius for the named platform."
      ],
      "tradeoffs": [
        "Coarsening enlarges the effective footprint (overreach)."
      ],
      "recommendedMetrics": [
        "overreachRatio",
        "coverageRatio"
      ]
    },
    {
      "id": "privacy_safe",
      "name": "Privacy-safe",
      "intent": "Aggregation that avoids small-cell re-identification and sparse-audience exposure.",
      "containmentMode": "center",
      "resolutionPolicy": "Enforce a minimum physical cell size and a minimum audience threshold; degrade resolution and suppress sparse cells.",
      "weighting": "none",
      "privacy": "k-anonymity threshold + sparse-cell suppression + minimum aggregation window.",
      "guarantees": [
        "No cell reported below the configured audience/area threshold."
      ],
      "tradeoffs": [
        "Resolution degradation and suppression reduce granularity."
      ],
      "recommendedMetrics": [
        "coverageRatio"
      ]
    }
  ]
}