-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathxsql-aef-nlcd-deck.py
More file actions
2421 lines (2268 loc) · 109 KB
/
Copy pathxsql-aef-nlcd-deck.py
File metadata and controls
2421 lines (2268 loc) · 109 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# /// script
# requires-python = ">=3.13"
# dependencies = [
# "marimo",
# "datafusion>=54.0.0",
# "xarray-sql[duckdb]==0.4.0rc1",
# "xarray",
# "zarr>=3",
# "h3ronpy>=0.22.0",
# "pyarrow>=25.0.0",
# "arro3-core",
# "geoarrow-rust-core",
# "obstore>=0.9.2",
# "async-geotiff>=0.4",
# "anywidget>=0.9",
# "numpy",
# "duckdb>=1.5.5",
# "pyproj",
# "pillow",
# ]
# ///
"""NLCD backed or not by AlphaEarth, anywhere in CONUS, at any zoom: the deck.gl build.
xsql-aef-nlcd-conus.py with the map as its OWN deck.gl widget instead of lonboard.
The reason is one accessor: deck's H3HexagonLayer takes a single `coverage` for the
whole layer, so the agreement paint (each hexagon scaled by how well AlphaEarth
backs NLCD's word there) had to be drawn as polygons built in the kernel (rings,
earcut in a worker, ~7 vertices x 16 bytes per cell). kepler.gl solved this years
ago with a ColumnLayer subclass that adds ONE instanced attribute,
`instanceCoverage`, and multiplies the column radius by it. That subclass is ~20
lines of this widget's JS, so every paint is the stock H3HexagonLayer: uint64
cell ids, an rgba array and a float32 coverage array cross the bridge, deck
tessellates on the GPU. Layers have real ids (no marimo `undefined` collision),
the NLCD raster is a plain TileLayer the kernel serves over anywidget custom
messages, and a click is deck's pick with an h3-js fallback (the click's lon/lat
to the frame's res; deck's GPU pick returns nothing inside marimo here, as it
did in the HRRR counties film).
The camera-driven fold is unchanged from the lonboard build:
every time the map settles, the ground under it is folded to H3 at the resolution
the zoom deserves, NLCD from its own overview pyramid and AlphaEarth from whichever
of its two source.coop copies can serve that rung:
res 11 (zoomed in) tge-labs/aef-mosaic the 10 m Zarr, native, one window
res 5-10 (zoomed out) tge-labs/aef the per-tile COGs' OVERVIEWS (mean
embeddings at 40..2560 m), many files
Both folds are the h3 UDF in DataFusion; NLCD's majority class and AlphaEarth's
mean vector meet on the cell. Per view: class prototypes, the agreement (sigmoid
over the own-vs-runner-up cosine margin), spherical k-means clusters. The strip
under the map has the four paints as toggles, none required (NLCD raster; agreement:
alpha + coverage; NLCD and AlphaEarth clusters: regular hexagons at coverage
0.8), the pickable legend, a click that lights the
hexagon and tells its story. Prototypes and clusters are PER VIEW: they say what
is typical of a class HERE, and cluster colors are arbitrary per fold.
Measured from home (2026-08-24): a COG opens in 0.8 s, 162 open concurrently in
1.8 s and read their 2560 m overviews in 0.7 s; the ~2,000 files that cover CONUS
are a cold ~30 s at the coarsest rung, then cached (open handles + folded frames).
The mosaic rung is a native 10 m read: ~1-2 s at zoom 12, 10-20 s at zoom 10.
Attribution: "The AlphaEarth Foundations Satellite Embedding dataset is produced by
Google and Google DeepMind." (CC-BY 4.0.)
Run: uv run marimo edit xsql-aef-nlcd-deck.py (or --sandbox)
"""
import marimo
__generated_with = "0.24.0"
app = marimo.App(width="full", sql_output="native")
@app.cell
def _():
import asyncio
import json
import math
import os
import tempfile
import time
import urllib.parse
import urllib.request
import numpy as np
import pyarrow as pa
import pyarrow.parquet as pq
import xarray as xr
import duckdb
import marimo as mo
import anywidget
import traitlets
from obstore.store import S3Store
from zarr.storage import ObjectStore
from async_geotiff import GeoTIFF, Window
from datafusion import udf
from xarray_sql import XarrayContext
from h3ronpy.vector import coordinates_to_cells
from pyproj import Transformer
import io
from PIL import Image
return (
GeoTIFF,
Image,
ObjectStore,
S3Store,
Transformer,
Window,
XarrayContext,
anywidget,
asyncio,
coordinates_to_cells,
duckdb,
io,
json,
math,
mo,
np,
os,
pa,
pq,
tempfile,
time,
traitlets,
udf,
urllib,
xr,
)
@app.cell(hide_code=True)
def _(mo):
mo.md("""
[](https://molab.marimo.io/github/github.com/kentstephen/x-sql-marimo/blob/main/xsql-aef-nlcd-deck.py)
# NLCD, backed or not by AlphaEarth, across CONUS
Fly anywhere in the lower 48. When the map settles, the ground in view is folded
to H3 at the resolution the zoom deserves: **NLCD** (majority class per hexagon,
from its own overview pyramid) and the **AlphaEarth Foundations embedding** (the
mean 64-vector per hexagon, from the 10 m mosaic when zoomed in and from the
per-tile COGs' overviews when zoomed out). Per view, each NLCD class gets a
*prototype* (the mean of its cells' vectors: what that word looks like to the
satellites here) and each hexagon an **agreement**: how clearly it sits closer to
its own class than to the nearest other one.
- **agreement** paint: NLCD's colors; faint and shrunken where the embedding does
not back the word.
- **NLCD** paint: regular hexagons, flat colors.
- **AlphaEarth** paint: the embedding on its own, k-means clusters, the legend
saying what each cluster is made of in NLCD terms.
Click a hexagon for its story; click a legend chip to isolate a class or cluster.
Prototypes and clusters are recomputed per view (local, honest, colors shift).
| leg | data | engine |
|---|---|---|
| land cover | Annual NLCD, 30 m + pyramid, EPSG:5070 (`kylebarron/usgs-landcover` mirror, COG) | obstore + async-geotiff tiles, DataFusion fold (h3 UDF) |
| embeddings, zoomed in | `tge-labs/aef-mosaic` (Zarr v3, 10 m, 64 x int8, no pyramid) | obstore + zarr, DataFusion fold (h3 UDF, 64 `avg()`) |
| embeddings, zoomed out | `tge-labs/aef` COGs' overviews (80..2560 m, per UTM tile) | obstore + async-geotiff, pyproj per tile, one DataFusion fold |
| score | join on cell, prototypes, sigmoid margin, k-means | numpy; DuckDB for the tables |
""")
return
@app.cell
def _():
# ---- constants ----------------------------------------------------------
YEAR_NLCD = 2024
YEAR_AEF = 2024 # 2017-2025 (NLCD's mirror ends at 2024)
# The zoom -> H3 ladder (the nlcd-zoom notebook's): BASE_RES at ZOOM0, one step
# finer every PER_RES zoom units, clamped, then coarsened until the view's
# expected cell count fits CELL_BUDGET (polygons, not H3HexagonLayer, so the
# budget is vertices: 150k hexagons is ~1M vertices).
# BASE_RES 6 (was 7): one step coarser at every zoom, Stephen's call after
# flying it (the coarse hexagons read better and cost a quarter of the bytes).
ZOOM0, PER_RES, BASE_RES = 6.2, 1.4, 6
MIN_RES, MAX_RES = 5, 11
CELL_BUDGET = 150_000
# Which NLCD overview each res reads (30 m native, ten doublings).
NLCD_LEVEL_FOR_RES = {5: 5, 6: 4, 7: 4, 8: 3, 9: 2, 10: 1, 11: 0}
# Which AlphaEarth source and level each res reads. Mosaic from MOSAIC_MIN_RES
# up (native 10 m); below that the COG overview index (0 = 20 m, 1 = 40 m,
# 2 = 80 m, 3 = 160 m, 4 = 320 m, 5 = 640 m, 6 = 1280 m, 7 = 2560 m), picked
# for ~15-50 overview pixels per cell.
# res 10 stays on the COGs (40 m): its padded box is ~2,800 km2, ~1.8 GB raw
# from the mosaic (a minute from home); res 11 (~360 km2, ~230 MB) is the
# first rung the mosaic serves in ~10 s.
MOSAIC_MIN_RES = 11
AEF_LEVEL_FOR_RES = {5: 7, 6: 6, 7: 4, 8: 3, 9: 2, 10: 1}
AEF_MAX_FILES = 2500 # more files than this and the view gets NLCD only
# The fold box is the flat camera footprint, padded, from a GUESSED canvas size
# (the HFP ruler is the port that measures it; not here yet).
VIEW_W, VIEW_H = 1400, 720
PAD = 1.3
SETTLE = 0.35 # seconds the camera must rest before a fold
# Below this zoom the map is NLCD as a picture (RasterLayer.from_geotiff, the
# COG's own tiles and colormap, served by the kernel); from it up, the
# agreement hexagons fold live for the small box in view (Stephen: "show
# something cheap like the raster, then when you zoom in the agreement hexes").
HEX_ZOOM = 9.0
# maplibre layer id deck draws BEFORE (under) in the interleaved basemap: the
# first label layer of Carto's Positron style (lonboard's viz() uses it too).
LABELS_SLOT = "watername_ocean"
RASTER_TILE = 256 # px per NLCD tile the kernel renders for the TileLayer
HOME = {"longitude": -96.0, "latitude": 38.5, "zoom": 4.0}
TAU = 0.02
MIN_CLASS_CELLS = 30
K_CLUSTERS = 10
CLUSTER_HEX = ["#0072B2", "#E69F00", "#56B4E9", "#F0E442", "#CC79A7",
"#009E73", "#D55E00", "#999999", "#7B4EA3", "#6B3F1D"]
ALPHA_MIN, ALPHA_MAX = 30, 235
COV_MIN = 0.30
# NLCD H3 / AlphaEarth clusters H3: regular hexagons at full coverage (0.8 was
# tried 2026-08-24 and put back, Stephen's call), a little below the agreement
# paint's top alpha
COV_FLAT = 1.00
ALPHA_FLAT = 190
# "color by agreement" (the strip's toggle on the agreement paint): the
# hexagons take a perceptual ramp on the agreement value instead of NLCD's
# color; cool = disagreement, warm = agreement (Stephen's default), and the
# highlight-disagreement checkbox reverses it so warm = disagreement. viridis
# because it has NO RED anywhere: its warm end is yellow, so neither
# direction of the flip lands on the weak leg (a blue-white-red cool/warm
# would). cividis is the alternative (same axis, flatter). 32 stops each,
# matplotlib's tables, interpolated to 256 in the frame cell; no matplotlib
# import. Coverage scaling stays; alpha is flat (a ramp's dark end fading to
# nothing would read as no data).
AGREE_CMAP = "viridis"
RAMPS = {
"viridis": "440154470d6048186a482374472e7c4538824241863e4a893a548c365d8d32658e2e6d8e2b758e287d8e25848e228c8d1f948c1e9c8920a38625ab822eb37c3aba7648c16e58c7656ccd5a7fd34e93d741a8db34c0df25d5e21aeae51afde725",
"cividis": "00224e00285b002e6a0533711c396f293f6e33446d3c4a6c45506c4d556c555b6d5c616e6467706b6d72727274787877807f78888578908b78979177a09875a89e73b0a571b9ab6dc2b369cbb965d3c05fdcc859e6d051efd748f8df3cfee838",
}
ALPHA_RAMP = 225
DIM_ALPHA = 22
# Boundaries around clusters of low-agreement cells: the set of cells with
# agreement below the strip's threshold (EDGE_THR seeds it) is dissolved in
# DuckDB (h3_cells_to_multi_polygon_wkb, H3's own outer-boundary walk, then
# ST_Dump into blobs); blobs under EDGE_MIN_CELLS cells (by area against the
# res's average cell) are speckle and dropped. Drawn as one PathLayer.
EDGE_THR = 0.5
EDGE_MIN_CELLS = 7
# each ring is painted the NLCD color of the blob's majority class (Stephen:
# "the same color as the NLCD hexes"), at this alpha and width
EDGE_ALPHA = 235
EDGE_WIDTH = 2 # px
NLCD_PREFIX = "kylebarron/usgs-landcover/annual-nlcd/c1/v1/cu/mosaic"
NLCD_NODATA = 250
AEF_PREFIX = "tge-labs/aef-mosaic"
AEF_RES, AEF_Y0, AEF_X0 = 8.983111749910169e-05, 83.68570533713473, -180.0
AEF_NODATA = -128
AEF_INDEX_URL = "https://data.source.coop/tge-labs/aef/v1/annual/aef_index.parquet"
CACHE_DIR = os.path.join(tempfile.gettempdir(), "x-sql-marimo", "aef-nlcd")
CLASSES = {
11: ("Open water", (70, 107, 159)),
12: ("Perennial ice/snow", (209, 222, 248)),
21: ("Developed, open space", (222, 197, 197)),
22: ("Developed, low", (217, 146, 130)),
23: ("Developed, medium", (235, 0, 0)),
24: ("Developed, high", (171, 0, 0)),
31: ("Barren", (179, 172, 159)),
41: ("Deciduous forest", (104, 171, 95)),
42: ("Evergreen forest", (28, 95, 44)),
43: ("Mixed forest", (181, 197, 143)),
52: ("Shrub/scrub", (204, 184, 121)),
71: ("Herbaceous", (223, 223, 194)),
81: ("Pasture/hay", (220, 217, 57)),
82: ("Cultivated crops", (171, 108, 40)),
90: ("Woody wetlands", (184, 217, 235)),
95: ("Emergent wetlands", (108, 159, 184)),
}
return (
AEF_INDEX_URL,
AEF_LEVEL_FOR_RES,
AEF_MAX_FILES,
AEF_NODATA,
AGREE_CMAP,
ALPHA_RAMP,
RAMPS,
AEF_PREFIX,
AEF_RES,
AEF_X0,
AEF_Y0,
ALPHA_FLAT,
ALPHA_MAX,
ALPHA_MIN,
BASE_RES,
CACHE_DIR,
CELL_BUDGET,
CLASSES,
CLUSTER_HEX,
COV_FLAT,
COV_MIN,
DIM_ALPHA,
EDGE_ALPHA,
EDGE_MIN_CELLS,
EDGE_THR,
EDGE_WIDTH,
HEX_ZOOM,
HOME,
K_CLUSTERS,
LABELS_SLOT,
MAX_RES,
MIN_CLASS_CELLS,
MIN_RES,
MOSAIC_MIN_RES,
NLCD_LEVEL_FOR_RES,
RASTER_TILE,
NLCD_NODATA,
NLCD_PREFIX,
PAD,
PER_RES,
SETTLE,
TAU,
VIEW_H,
VIEW_W,
YEAR_AEF,
YEAR_NLCD,
ZOOM0,
)
@app.cell
def _(math, np):
# ---- EPSG:5070 both ways, closed form (verified against pyproj to 3e-10 deg) ----
_a, _f = 6378137.0, 1 / 298.257222101
_e2 = 2 * _f - _f * _f
_e = math.sqrt(_e2)
_lat0, _lon0, _lat1, _lat2 = map(math.radians, (23.0, -96.0, 29.5, 45.5))
def _q(p):
s = np.sin(p)
return (1 - _e2) * (
s / (1 - _e2 * s * s) - (1 / (2 * _e)) * np.log((1 - _e * s) / (1 + _e * s))
)
def _m(p):
return math.cos(p) / math.sqrt(1 - _e2 * math.sin(p) ** 2)
_m1, _m2, _q1, _q2, _q0 = _m(_lat1), _m(_lat2), _q(_lat1), _q(_lat2), _q(_lat0)
_n = (_m1 * _m1 - _m2 * _m2) / (_q2 - _q1)
_C = _m1 * _m1 + _n * _q1
_rho0 = _a * math.sqrt(_C - _n * _q0) / _n
def albers_fwd(lon, lat):
lon, lat = np.radians(lon), np.radians(lat)
rho = _a * np.sqrt(_C - _n * _q(lat)) / _n
th = _n * (lon - _lon0)
return rho * np.sin(th), _rho0 - rho * np.cos(th)
def albers_inv(x, y):
rho = np.sqrt(x * x + (_rho0 - y) ** 2)
th = np.arctan2(x, _rho0 - y)
qq = (_C - rho * rho * _n * _n / (_a * _a)) / _n
phi = np.arcsin(qq / 2)
for _ in range(6):
s = np.sin(phi)
phi = phi + ((1 - _e2 * s * s) ** 2 / (2 * np.cos(phi))) * (
qq / (1 - _e2)
- s / (1 - _e2 * s * s)
+ (1 / (2 * _e)) * np.log((1 - _e * s) / (1 + _e * s))
)
return np.degrees(_lon0 + th / _n), np.degrees(phi)
return albers_fwd, albers_inv
@app.cell
def _(
BASE_RES,
CELL_BUDGET,
MAX_RES,
MIN_RES,
PAD,
PER_RES,
VIEW_H,
VIEW_W,
ZOOM0,
math,
):
# ---- the camera -> box and res --------------------------------------------
_CELL_KM2 = {5: 252.9, 6: 36.13, 7: 5.161, 8: 0.7373, 9: 0.1053, 10: 0.01505, 11: 0.00215}
def _lat_to_y(lat):
r = math.radians(lat)
return (1 - math.log(math.tan(r) + 1 / math.cos(r)) / math.pi) / 2
def _y_to_lat(y):
return math.degrees(math.atan(math.sinh(math.pi * (1 - 2 * y))))
def view_to_bbox(vs):
"""The flat camera footprint (W, S, E, N) from the view; the widget reports
its canvas size (`w`, `h`) with every move, the constants are the seed."""
world = 512 * (2 ** vs["zoom"])
w, h = vs.get("w") or VIEW_W, vs.get("h") or VIEW_H
half_lon = 360.0 * w / world / 2
yc, half_y = _lat_to_y(vs["latitude"]), h / world / 2
return (
vs["longitude"] - half_lon,
_y_to_lat(yc + half_y),
vs["longitude"] + half_lon,
_y_to_lat(yc - half_y),
)
def pad_box(b, f=PAD):
dx, dy = (b[2] - b[0]) * (f - 1) / 2, (b[3] - b[1]) * (f - 1) / 2
return (
max(-179.9, b[0] - dx),
max(-85.0, b[1] - dy),
min(179.9, b[2] + dx),
min(85.0, b[3] + dy),
)
def box_km2(b):
w = (b[2] - b[0]) * 111.32 * math.cos(math.radians((b[1] + b[3]) / 2))
return abs(w * (b[3] - b[1]) * 110.57)
def res_for_view(vs, box, dres=0):
"""The ladder's res for this zoom (+ the strip's offset), coarsened until the
box fits CELL_BUDGET."""
r = max(MIN_RES, min(MAX_RES, BASE_RES + dres + math.floor((vs["zoom"] - ZOOM0) / PER_RES)))
while r > MIN_RES and box_km2(box) / _CELL_KM2[r] > CELL_BUDGET:
r -= 1
return r
def contains(outer, inner):
return (
outer[0] <= inner[0] and outer[1] <= inner[1]
and outer[2] >= inner[2] and outer[3] >= inner[3]
)
return box_km2, contains, pad_box, res_for_view, view_to_bbox
@app.cell
def _(XarrayContext, coordinates_to_cells, pa, udf):
# THE FOLD IS THE H3 UDF INSIDE DATAFUSION (repo rule). One context, both folds.
ctx = XarrayContext()
ctx.register_udf(
udf(
lambda la, lo, r: pa.array(
coordinates_to_cells(la.to_numpy(), lo.to_numpy(), r[0].as_py())
),
[pa.float64(), pa.float64(), pa.int32()],
pa.uint64(),
"stable",
name="h3_latlng_to_cell",
)
)
return (ctx,)
@app.cell
async def _(
GeoTIFF,
Image,
NLCD_LEVEL_FOR_RES,
NLCD_NODATA,
NLCD_PREFIX,
RASTER_TILE,
S3Store,
Transformer,
Window,
YEAR_NLCD,
albers_fwd,
albers_inv,
asyncio,
ctx,
io,
math,
np,
time,
xr,
):
# ---- NLCD: the pyramid reader (the nlcd-zoom notebook's, by copy) + the fold ----
_store = S3Store(
"us-west-2.opendata.source.coop", region="us-west-2", skip_signature=True
)
_g = await GeoTIFF.open(
f"{NLCD_PREFIX}/Annual_NLCD_LndCov_{YEAR_NLCD}_CU_C1V1.tif", store=_store
)
_levels = [_g, *_g.overviews]
_L, _B, _R, _T = _g.bounds
# ---- the cheap paint: NLCD as Web Mercator tiles the kernel renders for the
# widget's TileLayer (deck asks over an anywidget custom message, the kernel
# answers with a PNG; lonboard's raster layer does the same under the hood).
# A tile is the COG level nearest the tile's ground resolution, sampled at the
# 256x256 output pixel centres through the closed-form Albers forward (the
# COG is EPSG:5070, the tiles are 3857): a nearest-neighbour reprojection in
# numpy, ~ms per tile. NLCD's own colormap, nodata -> alpha 0.
_cmap = _g.colormap.as_array()
_tf84 = Transformer.from_crs(_g.crs, "EPSG:4326", always_xy=True)
nlcd_bounds = _tf84.transform_bounds(*_g.bounds) # (W, S, E, N) lon/lat
_png_cache = {}
_blank = {"png": None}
_px0 = (_R - _L) / _levels[0].shape[1] # native pixel size, m
def _blank_png():
if _blank["png"] is None:
buf = io.BytesIO()
Image.new("RGBA", (1, 1), (0, 0, 0, 0)).save(buf, format="PNG")
_blank["png"] = buf.getvalue()
return _blank["png"]
async def nlcd_tile_png(z, x, y):
"""PNG bytes for Web Mercator tile (z, x, y), RASTER_TILE px square."""
key = (z, x, y)
if key in _png_cache:
return _png_cache[key]
n = 2 ** z
lon0, lon1 = x / n * 360 - 180, (x + 1) / n * 360 - 180
lat_n = math.degrees(math.atan(math.sinh(math.pi * (1 - 2 * y / n))))
lat_s = math.degrees(math.atan(math.sinh(math.pi * (1 - 2 * (y + 1) / n))))
if lon1 < nlcd_bounds[0] or lon0 > nlcd_bounds[2] or lat_n < nlcd_bounds[1] or lat_s > nlcd_bounds[3]:
return _blank_png()
T = RASTER_TILE
js = (np.arange(T) + 0.5) / T
lons = lon0 + js * (lon1 - lon0)
my = (y + js) / n
lats = np.degrees(np.arctan(np.sinh(np.pi * (1 - 2 * my))))
LON, LAT = np.meshgrid(lons, lats)
ax, ay = albers_fwd(LON.ravel(), LAT.ravel())
gres = 40075016.686 * math.cos(math.radians((lat_n + lat_s) / 2)) / (n * T)
li = int(max(0, min(len(_levels) - 1, round(math.log2(max(gres, _px0) / _px0)))))
H, W = _levels[li].shape
px = (_R - _L) / W
cols = np.floor((ax - _L) / px).astype(np.int64)
rows = np.floor((_T - ay) / px).astype(np.int64)
ok = (cols >= 0) & (cols < W) & (rows >= 0) & (rows < H) & np.isfinite(ax) & np.isfinite(ay)
if not ok.any():
_png_cache[key] = _blank_png()
return _png_cache[key]
c0, c1 = int(cols[ok].min()), int(cols[ok].max()) + 1
r0, r1 = int(rows[ok].min()), int(rows[ok].max()) + 1
arr, _ = await _read_window(li, c0, r0, c1 - c0, r1 - r0)
out = np.full(T * T, NLCD_NODATA, np.uint8)
out[ok] = arr[rows[ok] - r0, cols[ok] - c0]
out = out.reshape(T, T)
rgba = np.empty((T, T, 4), np.uint8)
rgba[..., :3] = _cmap[out]
rgba[..., 3] = np.where(out == NLCD_NODATA, 0, 255).astype(np.uint8)
buf = io.BytesIO()
Image.fromarray(rgba, mode="RGBA").save(buf, format="PNG")
_png_cache[key] = buf.getvalue()
if len(_png_cache) > 4000:
_png_cache.pop(next(iter(_png_cache)))
return _png_cache[key]
TILE = 512
TILE_BUDGET = 384 * 1024 * 1024
_tiles = {}
_held = {"bytes": 0}
_sem = asyncio.Semaphore(32)
async def _tile(li, ty, tx):
rd = _levels[li]
H, W = rd.shape
r0, c0 = ty * TILE, tx * TILE
h, w = min(TILE, H - r0), min(TILE, W - c0)
async with _sem:
ra = await rd.read(window=Window(col_off=c0, row_off=r0, width=w, height=h))
return np.asarray(np.ma.filled(ra.as_masked(), NLCD_NODATA)).reshape(h, w)
async def _read_window(li, col0, row0, wpx, hpx):
ty0, ty1 = row0 // TILE, (row0 + hpx - 1) // TILE
tx0, tx1 = col0 // TILE, (col0 + wpx - 1) // TILE
want = [(li, ty, tx) for ty in range(ty0, ty1 + 1) for tx in range(tx0, tx1 + 1)]
need = [k for k in want if k not in _tiles]
fetched = 0
if need:
got = await asyncio.gather(*(_tile(*k) for k in need))
for k, a in zip(need, got):
_tiles[k] = a
_held["bytes"] += a.nbytes
fetched += a.size
while _held["bytes"] > TILE_BUDGET and len(_tiles) > len(want):
for k in list(_tiles):
if k not in want:
_held["bytes"] -= _tiles.pop(k).nbytes
break
else:
break
out = np.full((hpx, wpx), NLCD_NODATA, dtype=np.uint8)
for k in want:
_, ty, tx = k
a = _tiles[k]
sr, sc = ty * TILE, tx * TILE
r0, c0 = max(row0, sr), max(col0, sc)
r1, c1 = min(row0 + hpx, sr + a.shape[0]), min(col0 + wpx, sc + a.shape[1])
if r1 <= r0 or c1 <= c0:
continue
out[r0 - row0 : r1 - row0, c0 - col0 : c1 - col0] = a[r0 - sr : r1 - sr, c0 - sc : c1 - sc]
return out, fetched
async def nlcd_fold(box, res):
"""Majority NLCD class per res cell over the box, from the level the res
deserves. Returns (arrow table, stats string)."""
t0 = time.time()
li = NLCD_LEVEL_FOR_RES[res]
rd = _levels[li]
H, W = rd.shape
px = (_R - _L) / W
W_, S_, E_, N_ = box
lons = np.concatenate([np.linspace(W_, E_, 9), np.full(9, E_), np.linspace(E_, W_, 9), np.full(9, W_)])
lats = np.concatenate([np.full(9, N_), np.linspace(N_, S_, 9), np.full(9, S_), np.linspace(S_, N_, 9)])
ax, ay = albers_fwd(lons, lats)
c0 = max(0, int((ax.min() - _L) / px))
c1 = min(W, int(math.ceil((ax.max() - _L) / px)))
r0 = max(0, int((_T - ay.max()) / px))
r1 = min(H, int(math.ceil((_T - ay.min()) / px)))
if c1 <= c0 or r1 <= r0:
return None, "NLCD: box outside CONUS"
arr, fetched = await _read_window(li, c0, r0, c1 - c0, r1 - r0)
xs = _L + (np.arange(c0, c1) + 0.5) * px
ys = _T - (np.arange(r0, r1) + 0.5) * px
X, Y = np.meshgrid(xs, ys)
lon, lat = albers_inv(X, Y)
t1 = time.time()
try:
ctx.deregister_table("lc")
except Exception:
pass
ctx.from_dataset(
"lc",
xr.Dataset(
{"cls": (("y", "x"), arr), "lat": (("y", "x"), lat), "lon": (("y", "x"), lon)},
coords={"y": ys, "x": xs},
),
chunks={"y": 512},
)
out = ctx.sql(f"""
WITH c AS (
SELECT h3_latlng_to_cell(lat, lon, CAST({res} AS INT)) AS cell, cls, count(*) AS n
FROM lc
WHERE cls != {NLCD_NODATA}
AND lon >= {W_} AND lon < {E_} AND lat >= {S_} AND lat < {N_}
GROUP BY 1, 2
)
SELECT cell,
first_value(cls ORDER BY n DESC, cls ASC) AS cls,
sum(n) AS npx,
CAST(max(n) AS DOUBLE) / sum(n) AS purity
FROM c GROUP BY cell
""").to_arrow_table()
return out, (
f"NLCD L{li} {arr.shape[1]:,}x{arr.shape[0]:,} px ({fetched / 1e6:.1f} Mpx fetched) "
f"{t1 - t0:.1f} s · fold {out.num_rows:,} {time.time() - t1:.1f} s"
)
return nlcd_bounds, nlcd_fold, nlcd_tile_png
@app.cell
async def _(
AEF_INDEX_URL,
AEF_LEVEL_FOR_RES,
AEF_MAX_FILES,
AEF_NODATA,
AEF_PREFIX,
AEF_RES,
AEF_X0,
AEF_Y0,
CACHE_DIR,
GeoTIFF,
MOSAIC_MIN_RES,
ObjectStore,
S3Store,
Transformer,
Window,
YEAR_AEF,
asyncio,
ctx,
duckdb,
math,
np,
os,
pa,
pq,
time,
xr,
):
# ---- AlphaEarth: two sources, one fold ------------------------------------
_store = S3Store(
"us-west-2.opendata.source.coop", region="us-west-2", skip_signature=True
)
_mstore = S3Store(
"us-west-2.opendata.source.coop", region="us-west-2", skip_signature=True, prefix=AEF_PREFIX
)
_ds = xr.open_zarr(ObjectStore(_mstore, read_only=True), chunks=None, consolidated=False)
_ti = int(np.where(_ds.time.values == YEAR_AEF)[0][0])
# The COG index for the year, cached as parquet under tmp (the full index is
# 302k rows over HTTP, ~10 s; the year's CONUS slice is a few thousand).
os.makedirs(CACHE_DIR, exist_ok=True)
_idx_path = os.path.join(CACHE_DIR, f"aef_index_{YEAR_AEF}.parquet")
if not os.path.exists(_idx_path):
_c = duckdb.connect()
_c.execute("INSTALL httpfs; LOAD httpfs")
_t = _c.execute(f"""
SELECT path, crs, utm_west, utm_south, utm_east, utm_north,
wgs84_west, wgs84_south, wgs84_east, wgs84_north
FROM read_parquet('{AEF_INDEX_URL}')
WHERE year = {YEAR_AEF}
AND wgs84_east > -125.5 AND wgs84_west < -66 AND wgs84_north > 24 AND wgs84_south < 50
""").arrow().read_all()
pq.write_table(_t, _idx_path)
_c.close()
aef_index = pq.read_table(_idx_path)
_IDX = {k: aef_index[k].to_numpy() for k in aef_index.column_names if k not in ("path", "crs")}
_PATHS = aef_index["path"].to_pylist()
_CRS = aef_index["crs"].to_pylist()
_open = {} # path -> GeoTIFF (headers only)
_sem = asyncio.Semaphore(64)
_tf_fwd, _tf_inv = {}, {}
def _tf(crs):
if crs not in _tf_fwd:
_tf_fwd[crs] = Transformer.from_crs("EPSG:4326", crs, always_xy=True)
_tf_inv[crs] = Transformer.from_crs(crs, "EPSG:4326", always_xy=True)
return _tf_fwd[crs], _tf_inv[crs]
async def _get(path):
rel = path.split("source.coop/")[1]
if rel not in _open:
async with _sem:
_open[rel] = await GeoTIFF.open(rel, store=_store)
return _open[rel]
async def _read_cog(i, li, box):
"""One file's overview window over the box: (int8 (64, h, w), lon, lat) or None.
Rows and columns go through the file's AFFINE TRANSFORM, not its bounds:
these COGs are stored SOUTH-UP (transform e = +10, origin at the south
edge; `bounds` reports bottom > top), and a north-up assumption mirrors
every tile within its 82 km (measured 2026-08-24: agreement 86-98% below
0.5 on the COG rungs, worse than random, against 14% from the mosaic).
"""
g = await _get(_PATHS[i])
ov = g.overviews[li]
H, W = ov.shape
t = g.transform
sx, sy = t.a * (g.width / W), t.e * (g.height / H) # signed overview pixel sizes
fwd, inv = _tf(_CRS[i])
W_, S_, E_, N_ = box
lons = np.concatenate([np.linspace(W_, E_, 5), np.full(5, E_), np.linspace(E_, W_, 5), np.full(5, W_)])
lats = np.concatenate([np.full(5, N_), np.linspace(N_, S_, 5), np.full(5, S_), np.linspace(S_, N_, 5)])
ux, uy = fwd.transform(lons, lats)
cc = (np.asarray(ux) - t.c) / sx
rr = (np.asarray(uy) - t.f) / sy
c0 = max(0, int(np.floor(np.nanmin(cc))))
c1 = min(W, int(np.ceil(np.nanmax(cc))))
r0 = max(0, int(np.floor(np.nanmin(rr))))
r1 = min(H, int(np.ceil(np.nanmax(rr))))
if c1 <= c0 or r1 <= r0:
return None
async with _sem:
ra = await ov.read(window=Window(col_off=c0, row_off=r0, width=c1 - c0, height=r1 - r0))
a = np.asarray(np.ma.filled(ra.as_masked(), AEF_NODATA)).reshape(64, r1 - r0, c1 - c0)
xs = t.c + (np.arange(c0, c1) + 0.5) * sx
ys = t.f + (np.arange(r0, r1) + 0.5) * sy
X, Y = np.meshgrid(xs, ys)
lon, lat = inv.transform(X, Y)
return a, lon, lat
_DEQ = ", ".join(
f"avg(signum(e{i:02d}) * power(e{i:02d} / 127.5, 2)) AS e{i:02d}" for i in range(64)
)
def _fold_rows(res, box, cols, lat, lon):
"""cols: int8 (64, n); lat/lon (n,). One 1-D Dataset, one fold."""
W_, S_, E_, N_ = box
ds1 = xr.Dataset(
{f"e{i:02d}": (("i",), cols[i]) for i in range(64)}
| {"lat": (("i",), lat), "lon": (("i",), lon)},
coords={"i": np.arange(lat.size)},
)
try:
ctx.deregister_table("aef")
except Exception:
pass
ctx.from_dataset("aef", ds1, chunks={"i": 262_144})
return ctx.sql(f"""
SELECT h3_latlng_to_cell(lat, lon, CAST({res} AS INT)) AS cell, count(*) AS naef, {_DEQ}
FROM aef
WHERE e00 != {AEF_NODATA}
AND lon >= {W_} AND lon < {E_} AND lat >= {S_} AND lat < {N_}
GROUP BY cell
""").to_arrow_table()
async def aef_fold(box, res):
"""Mean AlphaEarth vector per res cell over the box, from the mosaic (res >=
MOSAIC_MIN_RES) or the COG overviews. Returns (arrow table or None, stats)."""
t0 = time.time()
W_, S_, E_, N_ = box
if res >= MOSAIC_MIN_RES:
x0, x1 = int((W_ - AEF_X0) / AEF_RES), int((E_ - AEF_X0) / AEF_RES)
y0, y1 = int((AEF_Y0 - N_) / AEF_RES), int((AEF_Y0 - S_) / AEF_RES)
loop = asyncio.get_running_loop()
emb = await loop.run_in_executor(
None, lambda: _ds.embeddings.isel(time=_ti, y=slice(y0, y1), x=slice(x0, x1)).values
)
lat = AEF_Y0 - (np.arange(y0, y1) + 0.5) * AEF_RES
lon = AEF_X0 + (np.arange(x0, x1) + 0.5) * AEF_RES
LON, LAT = np.meshgrid(lon, lat)
t1 = time.time()
out = _fold_rows(res, box, emb.reshape(64, -1), LAT.ravel(), LON.ravel())
return out, (
f"AEF mosaic {emb.shape[2]:,}x{emb.shape[1]:,} px ({emb.nbytes / 1e6:.0f} MB) "
f"{t1 - t0:.1f} s · fold {out.num_rows:,} {time.time() - t1:.1f} s"
)
li = AEF_LEVEL_FOR_RES[res]
hit = np.where(
(_IDX["wgs84_east"] > W_) & (_IDX["wgs84_west"] < E_)
& (_IDX["wgs84_north"] > S_) & (_IDX["wgs84_south"] < N_)
)[0]
if len(hit) == 0:
return None, "AEF: no COG tiles under the view"
if len(hit) > AEF_MAX_FILES:
return None, f"AEF: {len(hit):,} tiles under the view (> {AEF_MAX_FILES:,}); zoom in for AlphaEarth"
parts = await asyncio.gather(*(_read_cog(int(i), li, box) for i in hit))
parts = [p for p in parts if p is not None]
if not parts:
return None, "AEF: nothing read"
cols = np.concatenate([p[0].reshape(64, -1) for p in parts], axis=1)
lon = np.concatenate([p[1].ravel() for p in parts])
lat = np.concatenate([p[2].ravel() for p in parts])
t1 = time.time()
out = _fold_rows(res, box, cols, lat, lon)
return out, (
f"AEF cog ov{li} ({10 * 2 ** (li + 1)} m) {len(parts):,} files {cols.shape[1] / 1e6:.2f} Mpx "
f"{t1 - t0:.1f} s · fold {out.num_rows:,} {time.time() - t1:.1f} s"
)
return aef_fold, aef_index
@app.cell
def _(
AGREE_CMAP,
ALPHA_FLAT,
ALPHA_MAX,
ALPHA_MIN,
ALPHA_RAMP,
CLASSES,
CLUSTER_HEX,
COV_FLAT,
COV_MIN,
DIM_ALPHA,
K_CLUSTERS,
MIN_CLASS_CELLS,
RAMPS,
TAU,
duckdb,
io,
np,
pa,
time,
):
# ---- a FRAME: scores, clusters, coverage and colors for one folded view ------
# GeoArrow for the boundaries (the counties film's transport): WKB rings ->
# geoarrow.linestring with INTERLEAVED coords (what @geoarrow/deck.gl-layers
# reads), through arro3 so the extension metadata survives into the IPC
# stream (pyarrow's own table constructor drops it, measured)
import pyarrow.ipc as pa_ipc
from geoarrow.rust.core import from_wkb as ga_from_wkb, linestring as ga_linestring
from arro3.core import Array as ArroArray, Table as ArroTable
# No hexagon geometry here: the widget's H3HexagonLayer draws from the cell
# ids, and the per-cell coverage is an attribute (the kepler-style column).
_PAL = np.array([tuple(int(h[i:i + 2], 16) for i in (1, 3, 5)) for h in CLUSTER_HEX], np.uint8)
# the agreement ramp: AGREE_CMAP's stops interpolated to a 256-entry LUT
_hx = RAMPS[AGREE_CMAP]
_stops = np.array([[int(_hx[i + j:i + j + 2], 16) for j in (0, 2, 4)] for i in range(0, len(_hx), 6)], np.float64)
_RAMP = np.stack(
[np.interp(np.linspace(0, 1, 256), np.linspace(0, 1, len(_stops)), _stops[:, k]) for k in range(3)], 1
).round().astype(np.uint8)
RAMP_HEX = ["#%02x%02x%02x" % tuple(int(v) for v in _RAMP[i]) for i in range(0, 256, 17)] # 16 swatches for the legend
con = duckdb.connect()
# h3 + spatial for the low-agreement boundaries (edges_for below); the fold
# itself stays the h3 UDF in DataFusion
con.execute("INSTALL h3 FROM community; LOAD h3; INSTALL spatial; LOAD spatial")
def build_frame(nlcd_cells, aef_cells):
"""Join the two folds, score, cluster, build both hexagon tables."""
import time as _time
_tt = {"t": _time.time()}
_lap = {}
def lap(name):
now = _time.time()
_lap[name] = now - _tt["t"]
_tt["t"] = now
con.register("nlcd_cells", nlcd_cells)
if aef_cells is None:
j = con.execute("SELECT cell, cls, npx, purity FROM nlcd_cells ORDER BY cell").arrow().read_all()
has_aef = False
else:
con.register("aef_cells", aef_cells)
j = con.execute("SELECT * FROM nlcd_cells JOIN aef_cells USING (cell) ORDER BY cell").arrow().read_all()
has_aef = True
n = j.num_rows
lap("join")
cls = j["cls"].to_numpy().astype(np.int64)
if has_aef and n > 0:
V = np.stack([j[f"e{i:02d}"].to_numpy() for i in range(64)], axis=1).astype(np.float32)
hom = np.linalg.norm(V, axis=1)
V = V / np.maximum(hom, 1e-9)[:, None]
present, counts = np.unique(cls, return_counts=True)
proto_classes = present[counts >= MIN_CLASS_CELLS]
if len(proto_classes) >= 2:
P = np.stack([V[cls == c].mean(0) for c in proto_classes])
P /= np.linalg.norm(P, axis=1)[:, None]
cos = V @ P.T
idx = np.searchsorted(proto_classes, cls)
has = np.isin(cls, proto_classes)
idx = np.where(has, idx, 0)
rows = np.arange(n)
own = np.where(has, cos[rows, idx], np.nan)
other = cos.copy()
other[rows, idx] = -np.inf
alt_i = other.argmax(1)
alt = np.where(has, proto_classes[alt_i], -1)
margin = own - other[rows, alt_i]
agree = np.where(has, 1.0 / (1.0 + np.exp(-margin / TAU)), np.nan)
else:
agree = np.full(n, np.nan)
alt = np.full(n, -1)
lap("score")
# spherical k-means (float32, 12 Lloyd steps: the assignment barely moves after)
k = min(K_CLUSTERS, n)
rng = np.random.default_rng(0)
C = V[rng.integers(n)][None, :]
for _ in range(1, k):
d = np.clip(1 - (V @ C.T).max(1), 1e-12, None).astype(np.float64)
C = np.vstack([C, V[rng.choice(n, p=d / d.sum())]])
clu = np.zeros(n, np.int64)
for _ in range(12):
new = (V @ C.T).argmax(1)
if (new == clu).all():
break
clu = new
for kk in range(k):
if (clu == kk).any():
C[kk] = V[clu == kk].mean(0)
C /= np.linalg.norm(C, axis=1)[:, None]
clu = (V @ C.T).argmax(1)
order = np.argsort(-np.bincount(clu, minlength=k))
clu = np.argsort(order)[clu]
lap("kmeans")
else:
hom = np.full(n, np.nan)
agree = np.full(n, np.nan)
alt = np.full(n, -1)
clu = np.zeros(n, np.int64)
cells = pa.table({
"cell": j["cell"],
"cls": pa.array(cls.astype(np.uint8)),
"name": pa.array([CLASSES.get(int(c), ("?",))[0] for c in cls]),
"cluster": pa.array(clu.astype(np.int16)),
"purity": j["purity"],
"homogeneity": pa.array(hom.astype(np.float32)),
"agree": pa.array(agree.astype(np.float32)),
"alt_name": pa.array([CLASSES.get(int(c), ("none",))[0] for c in alt]),
})
lap("table")
cov = np.where(np.isnan(agree), 1.0, COV_MIN + (1 - COV_MIN) * np.clip(agree, 0, 1)).astype(np.float32)
# the flat paints (NLCD H3, clusters H3): every hexagon at COV_FLAT
cov_flat = np.full(n, COV_FLAT, np.float32)
# highlight disagreement: coverage inverted too (the least-backed cells
# full-size and solid, the agreeing ones small and faint), else the two
# cues point opposite ways and the map reads as pale blobs with bold dots
cov_inv = np.where(np.isnan(agree), COV_MIN, COV_MIN + (1 - COV_MIN) * (1 - np.clip(agree, 0, 1))).astype(np.float32)
cellid = cells["cell"].to_numpy().astype(np.uint64)
lap("hex")
rgb = np.array([CLASSES.get(int(c), ("?", (128, 128, 128)))[1] for c in cls], np.uint8)
alpha_agree = np.where(
np.isnan(agree), ALPHA_MAX, ALPHA_MIN + (ALPHA_MAX - ALPHA_MIN) * np.clip(agree, 0, 1)
).astype(np.uint8)
# reversed: the least-backed (smallest) cells solid, the agreeing ones faint
# (Stephen: "so the smallest coverage cells are noticeable")
alpha_inv = np.where(
np.isnan(agree), ALPHA_MIN, ALPHA_MIN + (ALPHA_MAX - ALPHA_MIN) * (1 - np.clip(agree, 0, 1))
).astype(np.uint8)
rgb_clu = _PAL[clu % len(_PAL)]
# color by agreement: the ramp on the value (unscored cells grey);
# `inv` reverses it (warm = disagreement)
_ai = np.where(np.isnan(agree), 0, np.clip(agree, 0, 1) * 255).round().astype(np.int64)
_unscored = np.isnan(agree)[:, None]