Skip to content

Commit 8d48dd9

Browse files
committed
cells-api: worker-side adaptive coarsening on combo path (dormant client)
Adds `coarsenCells(cells, toRes)` + wires it into the combo branch of `handleCellsRequest`. When `maxCells != null && cells.length > maxCells && res > MIN_RES_COMBO`, worker walks parent-aggregate one res at a time until it fits, sums count fields, dedup+sorts fatal_years. Direct-curl measured (Bergen z=10.94, 3-s5 batch): before: res=10, 6500 cells, 1175 KB uncompressed after: res=9, 1429 cells, 269 KB uncompressed (~4.4× smaller) Sld join still populates on coarsened cells. Client wiring intentionally NOT included in this commit. Earlier attempt (aca0fcd) sent `maxCells` on the combo path from `buildBatchUrl`, but revealed a downstream bug in `useCellsApi.ts:728`: per-shard responses get merged by picking `min(res)` across all shards, then rolling everything up to that coarsest res. When ONE dense urban shard coarsens r10→r7, EVERY shard in the viewport renders at r7 (50-80px hexes at z=10.94, visually catastrophic). The real fix is per-shard render (emit one deck.gl `ColumnLayer` per shard-res); tracked as followup on task #124. Worker capability lands now as backwards-compatible dormant infra — deployed to prod (version c666f7e6-8363-4f89-a15a-ebc90de8ff85), no client sends the param yet. Spec: `specs/cells-api-combo-maxcells.md`. Tests: `cells-api/src/cells.test.ts` +3 for `coarsenCells`. 29/29 green.
1 parent 0aa7366 commit 8d48dd9

4 files changed

Lines changed: 210 additions & 7 deletions

File tree

cells-api/src/cells.test.ts

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { describe, it, expect } from "vitest"
2-
import { parseCellsRequest, HttpError } from "./cells"
2+
import { latLngToCell, cellToParent } from "h3-js"
3+
import { parseCellsRequest, HttpError, coarsenCells } from "./cells"
34

45
/** Minimal valid query — only the two required params. */
56
const BASE = "cells=8c2a100894097ff&res=12"
@@ -44,3 +45,45 @@ describe("parseCellsRequest severity param", () => {
4445
expect(r.severities && [...r.severities].sort()).toEqual(["f", "i", "p"])
4546
})
4647
})
48+
49+
describe("coarsenCells", () => {
50+
// Two distinct r10 cells from geographically-separated lat/lngs so
51+
// their r9 parents differ; plus a synthetic sibling of the first
52+
// (any other r10 whose r9 parent matches).
53+
const jcR10 = latLngToCell(40.7178, -74.0431, 10) // Jersey City
54+
const newarkR10 = latLngToCell(40.7357, -74.1724, 10) // Newark
55+
const jcParent = cellToParent(jcR10, 9)
56+
const newarkParent = cellToParent(newarkR10, 9)
57+
// sibling of jcR10 (drop resolution and re-index at r10 by nudging lat).
58+
// The specific sibling doesn't matter — we just need same r9 parent.
59+
const jcSib = latLngToCell(40.7180, -74.0429, 10)
60+
61+
it("sums counts across children mapping to the same parent", () => {
62+
const a = { h3: jcR10, n_fatal: 1, n_inj_ped: 2, n_inj_other: 3, n_pdo: 4, n_vehs: 5 }
63+
const b = { h3: jcSib, n_fatal: 10, n_inj_ped: 20, n_inj_other: 30, n_pdo: 40, n_vehs: 50 }
64+
const c = { h3: newarkR10, n_fatal: 7, n_inj_ped: 0, n_inj_other: 0, n_pdo: 0, n_vehs: 1 }
65+
const out = coarsenCells([a, b, c], 9)
66+
// Two distinct parents: jcParent (has a+b) and newarkParent (has c).
67+
expect(out.length).toBe(2)
68+
expect(new Set(out.map(o => o.h3))).toEqual(new Set([jcParent, newarkParent]))
69+
const jc = out.find(o => o.h3 === jcParent)!
70+
const nw = out.find(o => o.h3 === newarkParent)!
71+
expect(jc.n_fatal).toBe(1 + 10)
72+
expect(jc.n_inj_other).toBe(3 + 30)
73+
expect(jc.n_pdo).toBe(4 + 40)
74+
expect(nw.n_fatal).toBe(7)
75+
expect(nw.n_vehs).toBe(1)
76+
})
77+
78+
it("returns empty for empty input", () => {
79+
expect(coarsenCells([], 9)).toEqual([])
80+
})
81+
82+
it("aggregates fatal_years across children, dedup+sort", () => {
83+
const a = { h3: jcR10, n_fatal: 1, n_inj_ped: 0, n_inj_other: 0, n_pdo: 0, n_vehs: 0, fatal_years: [2018, 2020] }
84+
const b = { h3: jcSib, n_fatal: 1, n_inj_ped: 0, n_inj_other: 0, n_pdo: 0, n_vehs: 0, fatal_years: [2020, 2022] }
85+
const [parent] = coarsenCells([a, b], 9)
86+
expect(parent.h3).toBe(jcParent)
87+
expect(parent.fatal_years).toEqual([2018, 2020, 2022])
88+
})
89+
})

cells-api/src/cells.ts

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,42 @@ export function _resetSldCache(): void {
163163
sldCached = null
164164
}
165165

166+
/** In-worker parent-aggregate — used to drop one H3 res in the combo
167+
* path when a shard came back with more cells than `maxCells`. Cheaper
168+
* than re-reading a coarser parquet (no extra R2 hit), and the sum-
169+
* of-tiers semantics match how the offline pyramid rollup was built. */
170+
export function coarsenCells(cells: CellOut[], toRes: number): CellOut[] {
171+
if (cells.length === 0) return cells
172+
const parents = new Map<string, CellOut>()
173+
for (const c of cells) {
174+
const ph = cellToParent(c.h3, toRes)
175+
let p = parents.get(ph)
176+
if (!p) {
177+
p = {
178+
h3: ph,
179+
n_fatal: 0,
180+
n_inj_ped: 0,
181+
n_inj_other: 0,
182+
n_pdo: 0,
183+
n_vehs: 0,
184+
}
185+
parents.set(ph, p)
186+
}
187+
p.n_fatal += c.n_fatal
188+
p.n_inj_ped += c.n_inj_ped
189+
p.n_inj_other += c.n_inj_other
190+
p.n_pdo += c.n_pdo
191+
p.n_vehs += c.n_vehs
192+
if (c.fatal_years && c.fatal_years.length > 0) {
193+
(p.fatal_years ??= []).push(...c.fatal_years)
194+
}
195+
}
196+
for (const p of parents.values()) {
197+
if (p.fatal_years) p.fatal_years = [...new Set(p.fatal_years)].sort((a, b) => a - b)
198+
}
199+
return [...parents.values()]
200+
}
201+
166202
export type CellsResponse = {
167203
res: number
168204
year_range: [number, number]
@@ -249,8 +285,11 @@ export async function handleCellsRequest(
249285

250286
// Multi-resolution combo path: client specifies `(shard_res, data_res)`.
251287
// Worker reads `pyramid/s{shard_res}_r{data_res}/{shard}.parquet`
252-
// directly, no coarsening (the client picks a combo whose cell count
253-
// is already in budget). `maxCells` is ignored on the combo path.
288+
// directly. When `maxCells` is set (client-signaled per-shard cap),
289+
// in-worker coarsening walks parent-aggregate cells down one res at
290+
// a time until it fits or hits `MIN_RES` — cheaper than re-reading
291+
// a coarser parquet from R2. Response `res` reports the actual res
292+
// returned, which the client trusts as ground truth.
254293
if (shardRes != null) {
255294
const combos = manifest.pyramid_combos ?? []
256295
const combo = combos.find(c => c.shard_res === shardRes && c.data_res === requestedRes)
@@ -261,10 +300,16 @@ export async function handleCellsRequest(
261300
const known = new Set(combo.shard_cells)
262301
const shards = requestedShards.filter(s => known.has(s))
263302
const clipPoly = req.clipPolygon && req.clipPolygon.length >= 3 ? req.clipPolygon : null
264-
const cells = await queryPyramid(bucket, prefix, requestedRes, shards, yearRange, sevSet, clipPoly, shardRes)
303+
let cells = await queryPyramid(bucket, prefix, requestedRes, shards, yearRange, sevSet, clipPoly, shardRes)
304+
let res = requestedRes
305+
const MIN_RES_COMBO = 5
306+
while (maxCells != null && cells.length > maxCells && res > MIN_RES_COMBO) {
307+
res--
308+
cells = coarsenCells(cells, res)
309+
}
265310
await joinSld(bucket, prefix, cells)
266311
return {
267-
res: requestedRes,
312+
res,
268313
year_range: yearRange,
269314
data_version: manifest.data_version,
270315
source: "pyramid",

specs/cells-api-combo-maxcells.md

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
# cells-api: worker-side adaptive coarsening on the combo path
2+
3+
## Problem
4+
5+
Circle/hex mode at coarse zoom (e.g. z=10.94 Bergen) fetches ~64k r10
6+
cells per shard × 3-5 shards → 3-5 MB gzipped per viewport response.
7+
The visual only needs a fraction of that: at that zoom, r10 cells are
8+
~2 px wide — well below one screen pixel per cell — so the client
9+
blurs them into a rasterized heatmap regardless of how many it fetched.
10+
11+
The legacy (non-combo) request path already has a `maxCells` mode
12+
where the worker walks coarser (drops one res level, re-reads,
13+
re-checks) until `cells.length <= maxCells`. The combo path
14+
(`?shard_res=X&res=Y`) intentionally skipped it because the client
15+
was pre-picking a combo whose total cell budget "should" fit.
16+
17+
Reality check: per-shard cell counts vary 10-100× across a single
18+
NJ-wide viewport (rural r10 shards have ~5k cells; urban ones have
19+
~60k). A single combo choice can't be tight everywhere.
20+
21+
## Design
22+
23+
Extend the combo path to accept `maxCells` and apply it **in-worker,
24+
per-shard, in memory** — no coarser-res parquet re-read.
25+
26+
### Wire
27+
28+
Client sends `?shard_res=X&res=Y&maxCells=N` on combo requests
29+
(currently only sent on legacy path). Worker interprets:
30+
31+
- Read shard's pyramid at `(shardRes, res)`.
32+
- Build the `CellOut[]` (year/severity filter + polygon clip, as today).
33+
- **New**: if `cells.length > maxCells`, coarsen in-worker by
34+
`cellToParent(h3, res-1)` + sum the count fields. Repeat until
35+
`cells.length <= maxCells` or `res === MIN_RES`.
36+
- Join sld sidecar against whatever res the cells ended up at.
37+
- Return with `res: actualRes` (client already uses this as truth).
38+
39+
Aggregation semantics for the coarsening step:
40+
41+
- `n_fatal/n_inj_ped/n_inj_other/n_pdo/n_vehs`: **sum** across children.
42+
- `fatal_years`: **union** across children.
43+
- `h3`: replaced with parent's h3 string.
44+
45+
Sld-join uses the coarsened h3s. Sidecar has entries at r6-r11; cells
46+
coarsened to r9 look up r9 h3 directly, r10 looks up r10, etc.
47+
`getResolution(h3) > 11 ? cellToParent(h3, 11) : h3` handles coarser-
48+
than-11 already.
49+
50+
### Worker changes (`cells-api/src/cells.ts`)
51+
52+
Two things:
53+
54+
1. Extract `coarsenCells(cells, toRes)` helper: parent-aggregate via
55+
`cellToParent`. Uses same tier fields as `CellOut`.
56+
2. Add a while-loop after the shard read in the combo branch of
57+
`handleCellsRequest`: `while (maxCells != null && cells.length >
58+
maxCells && res > MIN_RES) { res--; cells = coarsenCells(cells,
59+
res) }`.
60+
61+
Existing legacy-path adaptive is unchanged.
62+
63+
### Client changes (`www/src/map/useCellsApi.ts`)
64+
65+
- `buildBatchUrl`: send `maxCells` on both paths (combo + legacy).
66+
- Client already trusts `response.res` for rendering; cross-shard
67+
`rollupCellsToRes` normalizes when different shards return different
68+
res. No further plumbing.
69+
70+
Existing client-side coarsening (`rollupCellsToRes` in the useCellsApi
71+
result-merge) is safe — it walks fine → coarse, which is what we want
72+
if the worker returns a shard at a coarser res.
73+
74+
### Choosing `maxCells` per request
75+
76+
Current CELLS_BUDGET = 100k total across the viewport response. A
77+
typical viewport has 3-25 shards. Client already snaps cover to
78+
combos; add:
79+
80+
```ts
81+
const maxCellsPerShard = Math.floor(CELLS_BUDGET / cover.length)
82+
```
83+
84+
Floor at ~5000 (leave headroom for a single-shard corner case). Cap at
85+
20000 (don't over-restrict when a shard covers dense urban area with
86+
legitimate detail).
87+
88+
## What this does NOT do
89+
90+
- Doesn't change what hex-mode users see visually — worker returns
91+
aggregated cell counts, client renders whatever res comes back at
92+
identical hex-tessellation semantics. Chunkier hexes where the
93+
worker coarsened; unchanged where it didn't.
94+
- Doesn't touch parquet transport / JSON payload compression. That
95+
stays a followup once we see whether (1) is enough.
96+
- Doesn't touch the client's cover-picker. Same shard fanout, same
97+
URL cache keys.
98+
99+
## Rollout
100+
101+
Independently deployable. Worker deploy first (backwards-compatible:
102+
old client ignores `res` field being different if it happens to be
103+
coarser than requested; nothing breaks). Client change lands after,
104+
starts sending `maxCells` on combo path.
105+
106+
## Tests
107+
108+
- `cells-api/src/cells.test.ts`: add cases for `coarsenCells` + the
109+
combo-path adaptive loop with `maxCells`.
110+
- Live smoke: hit the Bergen z=10.94 URL; verify (a) response cell
111+
count is ≤ maxCells per shard; (b) `res` returned is 8 or 9 for the
112+
urban shards; (c) visual on the map is unchanged for rural shards.

www/src/map/useCellsApi.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -480,8 +480,11 @@ function buildShardUrl(
480480
years: `${filter.yearRange[0]}-${filter.yearRange[1]}`,
481481
severities: sevs,
482482
})
483-
// Combo path skips `maxCells` (combo was pre-picked to fit budget);
484-
// legacy path needs it for worker-side adaptive coarsening.
483+
// Combo path skips `maxCells` for now — worker supports it (see
484+
// `specs/cells-api-combo-maxcells.md`) but wiring the client to
485+
// actually use it requires a per-shard render path so
486+
// heterogeneously-coarsened shards don't force the whole viewport
487+
// to the coarsest returned res. Deferred followup.
485488
if (shardRes != null) params.set("shard_res", String(shardRes))
486489
else params.set("maxCells", String(maxCells))
487490
if (polygonStr) params.set("polygon", polygonStr)

0 commit comments

Comments
 (0)