Purpose
Google Ads, most DSPs, and most mobile geofencing SDKs share a common execution primitive: a location target is a circle — a point plus a radius, subject to a platform-specific minimum radius, a rounding increment, and a cap on how many targets a single campaign or ad group may carry. Executing an H3 cell set on such a platform is not one conversion but a repeated application of a circle choice — one circle per cell — subject to those platform constraints. This page is the execution-layer counterpart to the single-cell circle pages; it covers what changes when "one circle" becomes "many circles under a budget."
Source geometry and destination geometry
Source is an h3_cell_set at a stated resolution. Destination is a list
of point_radius targets, one per cell (in the naive case) or fewer, after
optimization (see below) — each entry a center, a radius, and whatever
platform-native target id the upload API returns.
Exactness class
Approximate, and for two independent reasons layered on top of each other: first, whichever single-cell circle is chosen (inner, outer, or equal-area) already carries that construction's own gap or overlap behavior; second, the platform's own radius rounding and minimum-radius floor perturb the chosen radius again after it is computed. Never describe this as "targeting the H3 cells" without naming both the circle mode and the platform's rounding behavior.
Containment rule and boundary behavior
- Inner (inscribed) per cell
- One circle per cell, no cross-cell overlap, systematic gaps at every cell's corners. Use when double-counted reach or double-billed impressions across adjacent cells is the primary risk to avoid.
- Outer (circumscribed) per cell
- One circle per cell, guaranteed per-cell coverage, systematic overlap with every neighboring cell's circle. Use when under-delivery to any part of a targeted cell is the primary risk to avoid.
- Equal-area per cell
- One circle per cell, area-matched but neither contained by nor containing its cell; gaps and overlaps both present. Use only for reach/budget planning math upstream of the actual buy, never as the executed target list itself.
Whichever mode is chosen, the platform performs its own deduplication
across overlapping circles at the audience level — a device inside two
overlapping outer circles is generally billed and reported once by the
platform's own frequency logic, not twice — but that dedup logic is
platform-internal and not something this conversion can verify from the
geometry alone. Treat duplicate_eligibility_area
(duplicateEligibilityAreaM2 on the actual circle set) as the upper bound
on the platform's exposure to double-counting, not as a guarantee of
what the platform actually reports.
Resolution behavior
Coarser resolutions mean fewer, larger circles — cheaper against a target-count cap, but each circle's absolute overreach or underreach grows with cell size even though the relative ratio (per the single-cell circle pages) stays roughly constant for regular hexagons. Finer resolutions mean more, smaller, tighter circles that are more likely to collectively exceed a platform's target-count cap before they exceed its accuracy needs — the resolution choice here is frequently constrained by the target-count limit first and the desired precision second.
Units and CRS
Centers are EPSG:4326 decimal degrees; radii are meters unless the platform's upload API specifies otherwise (some ad platforms accept radius in miles or kilometers and round differently in each unit — verify the platform's documented unit before submitting, since a silent unit mismatch is functionally a 1.6× or 0.62× scale error on every target).
Algorithm
function executeAsPointRadius(cells, mode, platformLimits):
circles = []
for cell in cells:
c = computeCircle(cell, mode) # inscribed | circumscribed | equalArea
r = clamp(c.radiusMeters, platformLimits.minRadius, platformLimits.maxRadius)
r = roundToIncrement(r, platformLimits.radiusIncrement)
circles.append({ center: c.center, radiusMeters: r })
if len(circles) > platformLimits.maxTargets:
circles = optimizeCircleCover(cells, platformLimits) # advanced, see below
return circles
import { circumscribedCircle, inscribedCircle, equalAreaCircle } from "@/lib/h3/circles";
function toPlatformTargets(
cells: string[],
mode: "inscribed" | "circumscribed" | "equal_area",
minRadiusM: number,
radiusIncrementM: number,
) {
const build = mode === "inscribed" ? inscribedCircle
: mode === "circumscribed" ? circumscribedCircle
: equalAreaCircle;
return cells.map((cell) => {
const c = build(cell);
const rounded = Math.max(
minRadiusM,
Math.ceil(c.radiusMeters / radiusIncrementM) * radiusIncrementM,
);
return { lat: c.center[0], lng: c.center[1], radiusMeters: rounded };
});
}
The same conversion with the Python bindings (h3-py v4):
import math
import h3
# Reuses the same densify-and-measure routines from the inscribed/
# circumscribed circle pages; only the aggregation (min vs max vs area)
# and the platform clamp/round step differ per mode.
def circle_for_mode(cell: str, mode: str):
center = h3.cell_to_latlng(cell)
if mode == "equal_area":
area_m2 = h3.cell_area(cell, unit="m^2")
return center, math.sqrt(area_m2 / math.pi)
boundary = h3.cell_to_boundary(cell)
n = len(boundary)
dists = []
for i in range(n):
a, b = boundary[i], boundary[(i + 1) % n]
for sample in densify_edge_geodesic(a, b): # see h3-to-inscribed-circle
dists.append(h3.great_circle_distance(center, sample, unit="m"))
if mode == "inscribed":
return center, min(dists) * (1 - 1e-4)
if mode == "circumscribed":
return center, max(dists) * (1 + 1e-4)
raise ValueError(f"unknown mode: {mode}")
def to_platform_targets(cells, mode, min_radius_m, radius_increment_m):
targets = []
for cell in cells:
(lat, lng), radius_m = circle_for_mode(cell, mode)
rounded = max(
min_radius_m,
math.ceil(radius_m / radius_increment_m) * radius_increment_m,
)
targets.append({"lat": lat, "lng": lng, "radiusMeters": rounded})
return targets
h3-py provides no per-cell circle helper for any of the three modes and
no minimum-radius/rounding logic — both are reproduced here: the min/max
great-circle-distance aggregation over a densified boundary for
inscribed/circumscribed (identical algorithm to the dedicated circle
pages), the area-only formula for equal-area, and a plain max/ceil
clamp-and-round for the platform floor and increment. The tested
reference implementation is the TypeScript in lib/.
Parameters
Circle mode (inner/outer/equal-area), the platform's minimum radius, maximum radius, radius rounding increment, and maximum target count per campaign/ad-group — all platform-specific and none safe to assume from another platform's documented limits.
Outputs
A list of { center, radiusMeters, platformTargetId } entries, tagged with
the circle mode used and the resolution the source cell set was normalized
to, so a later audit can reconstruct why a given radius does not exactly
match circumscribedCircle(cell).radiusMeters (rounding, or a min-radius
floor, will have moved it).
Quality metrics
overreach_ratio and underreach_ratio from the underlying circle mode
still apply per cell, plus duplicate_eligibility_area across the full
target set once rounding has been applied — rounding up to a minimum
radius or to the next increment always increases overreach relative to the
raw circle, never decreases it, since the rounding direction favors
meeting the platform's floor.
Edge cases
A minimum-radius floor turns a small, fine-resolution cell's already-small inscribed circle into a circle much larger than the cell itself once the floor is applied — at that point the "inner" circle mode no longer has its containment guarantee, because the platform, not this conversion, has overridden the radius. Radius rounding to a platform's fixed increment (for example, whole kilometers or quarter-miles) means the delivered radius is never exactly the computed one; round up when the intent was outer/coverage-guaranteeing and round down when the intent was inner/containment-guaranteeing, and document which direction was used, since rounding in the wrong direction silently flips a construction's guarantee.
Advanced: optimized circle cover
When the naive one-circle-per-cell list exceeds a platform's target-count
cap, an experimental alternative is to solve a small circle-cover problem
instead: cluster nearby cell centers, replace several small circles with
fewer, larger ones sized to bound total overreach, and stop once the
target count is under the cap. This is the platform_limit_optimized
conversion profile — containmentMode: intersect, circleMode: circumscribed, resolution policy "compact and coarsen until under the cap
and above the minimum radius," with the profile's own stated guarantee
limited to "respects maxTargets and minRadius for the named platform"
and its stated tradeoff explicit: "coarsening enlarges the effective
footprint." Treat this path as experimental and always report
overreach_ratio and coverage_ratio against the original cell set, not
just against the coarsened one, since the coarsening step itself is a
second, compounding approximation on top of the circle choice.
Assumptions and limitations
This conversion assumes the platform's declared minimum radius, maximum
radius, rounding increment, and target-count cap are current — these
change per platform and per ad product without notice, and a value cached
from a prior integration can silently violate the platform's actual
current limits. It also assumes the platform's own cross-target
deduplication is at least as conservative as the geometry suggests;
verify this against platform documentation rather than assuming it, since
duplicate_eligibility_area computed here is a geometric upper bound, not
a report of what the platform will actually bill or attribute.
