Rasterflow, Earth Intelligence & inference engine now in public preview Learn More

El Niño 2026, atmospheric rivers, and California’s burn scars: one SQL engine, two data models

Governor Gavin Newsom proclaimed a state of emergency on 21 September 2026 to prepare California for El Niño 2026. It names recent wildfire areas as particularly susceptible to mud and debris flows. Above Altadena, the Eaton fire left 42 basins that the US Geological Survey (USGS) rates high hazard for debris flows. USGS computes the rating for a 15-minute burst of 40 mm/h of rain (1.57 in/hour), and an atmospheric river can deliver that kind of deluge. The rating covers the basins on the hillside. The homes and roads downslope of the basins fall outside the rating, and the queries here count them.

WherobotsDB does the counting in SQL, reading every dataset in place. A join to the USGS basins returns 7,713 mapped building footprints inside the high-hazard basins or within 500 m (1,640 ft) of them. A raster vector join over elevation returns 388 km (241 mi) of mapped road and path.

Start with elevation, buildings and roads in the catalog

All four datasets sit in wherobots_open_data: the ASTER GDEM 30 m (98 ft) elevation model, Overture’s buildings and roads, and Regrid’s parcel sample. The companion notebook fetches the CAL FIRE perimeter and USGS basins. It also registers every shared view, so each query below runs as written, with no setup of its own. Each elevation row stores a footprint polygon, so WherobotsDB selects rows by location before it reads any pixel. Ten rasters cover the Eaton area.

Filter on the bounding box before the spatial test

Overture features carry a bbox struct beside the geometry. With a range filter on it before ST_Intersects, the first building count finished in 22.8 seconds. Without it, the run was cancelled at 7% after three minutes. The range check on four numbers is cheap, so the costly geometry test runs only on the rows that pass.

Show the code
-- scar holds the Eaton perimeter; the notebook fetches it from CAL FIRE and registers it as a view
WITH aoi AS (
  SELECT ST_Transform(
           ST_Buffer(ST_Transform(g, 'EPSG:4326', 'EPSG:32611'), 3000),
           'EPSG:32611', 'EPSG:4326') AS g
  FROM scar
),
bld AS (
  SELECT t.id, t.geometry AS geom
  FROM wherobots_open_data.overture_maps_foundation.buildings_building t
  WHERE t.bbox.xmin BETWEEN -118.20 AND -117.97   -- range scan first
    AND t.bbox.ymin BETWEEN  34.13  AND  34.27
)
SELECT count(*) FROM bld b JOIN aoi ON ST_Intersects(b.geom, aoi.g);

118,746 footprints pass the bounding box, 83,930 fall within 3 km (1.9 mi) of the perimeter, and 10,652 sit inside the scar.

Each of two questions below gets its own raster vector join. For buildings the answer is one elevation per footprint, so the first join reads the raster under every footprint. For roads, however, the answer is a length. So the second join turns the raster into a zone polygon first, then clips each road segment against it.

Buildings read the elevation under each footprint a b c elevation + building footprints RS_ZonalStats building elevation a 222 m b 285 m c 201 m one value per building, kept if below 230 m Roads turn the elevation into a zone, then clip each road 1 2 elevation below 230 m zone + roads 1 RS_MapAlgebra marks the cells below the line 2 RS_Polygonize turns them into polygons, ST_Union_Aggr merges them into one zone, and ST_Intersection measures the part of each road inside it
The two joins used below, drawn on a toy elevation grid with a 230 m (755 ft) line.

Buildings: read the elevation under each footprint

Which building footprints sit below the burn scar’s lowest point?

15,233 of the 83,930 footprints sit below that point, 223 m (732 ft). RS_ZonalStats runs the raster vector join per building, so every footprint gets one elevation value of its own. That value becomes one more column beside the building’s other attributes.

Show the code
-- scar, dem and inaoi (the 83,930 footprints within 3 km) are views from the notebook's setup cell
WITH scar_floor AS (
  SELECT min(RS_ZonalStats(d.rast, s.g, 1, 'min', true)) AS m FROM dem d, scar s
),
be AS (
  -- pool pixels across every raster a footprint touches: total value over pixel count
  SELECT i.id,
         sum(RS_ZonalStats(d.rast, i.geom, 1, 'sum', true))
           / NULLIF(sum(RS_ZonalStats(d.rast, i.geom, 1, 'count', true)), 0) AS elev
  FROM inaoi i JOIN dem d ON RS_Intersects(d.rast, i.geom)
  GROUP BY i.id
)
SELECT count(*) AS below_scar_floor
FROM be, scar_floor
WHERE be.elev < scar_floor.m;

Footprints that straddle two rasters have their pixels pooled before averaging, since the rasters meet under them. 380 of the 384 stay on the same side of the line. A 30 m (98 ft) pixel is wider than many houses, but every footprint still got an elevation value from the query.

2026-09-24T20:53:29.930329 image/svg+xml Matplotlib v3.11.2, https://matplotlib.org/ 500 750 1,000 1,250 1,500 1,750 2,000 2,250 feet 200 300 400 500 600 700 mean elevation under the footprint (m) 0 2,000 4,000 6,000 8,000 10,000 buildings per 20 m (66 ft) band scar’s lowest point, 223 m (732 ft) 15,233 below it 146 more sit above 700 m (2,297 ft), up to 1,809 m (5,935 ft), off the chart
Mean elevation under each of the 83,930 footprints within 3 km (1.9 mi) of the perimeter, in 20 m (66 ft) bands. Blue marks the buildings below the scar’s lowest point.

Roads: a raster vector join through a low-ground zone

Why do roads need a different join than buildings?

Roads need a different join because length, not a single point, decides the answer. A building gets one average elevation, and that single value places it above or below the line. A 1 km (0.6 mi) road dips below the line for a short stretch and stays above it for the rest. An average across the whole road would place it on one side, and the low stretch would go uncounted. The query takes three steps instead. RS_MapAlgebra marks every elevation pixel below 223 m (732 ft). RS_Polygonize turns the marked pixels into polygons, and ST_Union_Aggr merges them into one shape of low ground. ST_Intersection then cuts each road at the polygon’s edge, and the query adds up the length inside it. WherobotsDB builds the polygon once and tests every road against that same shape.

Show the code
-- aoi, dem and roads are views from the notebook's setup cell
WITH masked AS (
  SELECT RS_MapAlgebra(rast, 'D', 'out[0] = rast[0] < 223 ? 1 : 0;') AS m FROM dem
),
polys AS (
  SELECT t.p.geom AS g
  FROM masked LATERAL VIEW explode(RS_Polygonize(m, 1)) t AS p
  WHERE t.p.value = 1.0
),
low AS (
  SELECT ST_Intersection(u.g, a.g) AS g
  FROM (SELECT ST_Union_Aggr(ST_MakeValid(g)) AS g FROM polys) u, aoi a
)
SELECT r.class,
       sum(ST_Length(ST_Transform(ST_Intersection(r.geom, low.g),
                                  'EPSG:4326', 'EPSG:32611'))) / 1000 AS km
FROM roads r JOIN low ON ST_Intersects(r.geom, low.g)
GROUP BY r.class
ORDER BY km DESC;
2026-09-24T20:53:30.030379 image/svg+xml Matplotlib v3.11.2, https://matplotlib.org/ 0 20 40 60 80 100 120 140 160 kilometres inside the low zone Residential Service Footway Motorway Primary Tertiary Secondary Other 121.0 km (75.2 mi) 97.6 km (60.6 mi) 60.2 km (37.4 mi) 24.0 km (14.9 mi) 22.1 km (13.7 mi) 20.1 km (12.5 mi) 19.8 km (12.3 mi) 23.0 km (14.3 mi)
Kilometres of Overture road and path inside the 16.8 km² (6.5 sq mi) of ground below the scar’s lowest point, within 3 km (1.9 mi) of the perimeter.

The low ground covers 16.8 km² (6.5 sq mi) and holds 388 km (241 mi) of road and path within 3 km (1.9 mi) of the scar. Of that, 305 km (190 mi) is drivable and 24 km (15 mi) is motorway. Lengths follow each mapped segment, so both carriageways of every divided road are counted in full.

Joining to what USGS already modelled

An elevation line is only a screen. USGS publishes the basins as a live feature service, so they join in as one more vector layer. Each building counts once per distance, however many basins it touches.

Show the code
-- high holds the 42 dissolved high-hazard basins; the notebook fetches them from USGS
WITH z AS (
  SELECT g AS inside,
         ST_Transform(ST_Buffer(ST_Transform(g, 'EPSG:4326', 'EPSG:32611'), 500),  'EPSG:32611', 'EPSG:4326') AS b500,
         ST_Transform(ST_Buffer(ST_Transform(g, 'EPSG:4326', 'EPSG:32611'), 1000), 'EPSG:32611', 'EPSG:4326') AS b1k
  FROM high
)
SELECT sum(CASE WHEN ST_Intersects(b.geom, z.inside) THEN 1 ELSE 0 END) AS inside,
       sum(CASE WHEN ST_Intersects(b.geom, z.b500)   THEN 1 ELSE 0 END) AS inside_or_500m,
       sum(CASE WHEN ST_Intersects(b.geom, z.b1k)    THEN 1 ELSE 0 END) AS inside_or_1km
FROM bld b, z
WHERE ST_Intersects(b.geom, z.b1k);
2026-09-24T20:53:30.095776 image/svg+xml Matplotlib v3.11.2, https://matplotlib.org/ 0 2,500 5,000 7,500 10,000 12,500 15,000 17,500 20,000 Overture building footprints Inside a high-hazard basin Inside or within 500 m (1,640 ft) Inside or within 1 km (0.6 mi) 696 7,713 16,748
Overture building footprints inside, or within 500 m (1,640 ft) and 1 km (0.6 mi) of, the 42 Eaton basins that USGS rates high combined hazard.

696 footprints sit inside a high-hazard basin, 7,713 inside or within 500 m (1,640 ft), and 16,748 within 1 km (0.6 mi). The map below plots the 7,713 on Sentinel-2 imagery from 5 August 2026. They form a solid band along the scar’s lower edge, where the basins drain onto the valley floor. Nineteen months on, the scar is still bare.

Raster vector join result: 7,713 building footprints near 42 high-hazard debris-flow basins below the Eaton burn scar in Altadena

The 7,713 footprints (cyan) inside or within 500 m (1,640 ft) of the 42 high-hazard basins (red), on Sentinel-2 imagery from 5 August 2026.

Joining parcels to put a value on the screen

Building counts measure how many structures sit in each screen, not what they are worth. Regrid’s parcel sample in wherobots_open_data.partner_samples adds use codes, acreage and assessed values, and a join to the exposed footprints carries those onto both screens. The join runs in two steps so nothing is counted twice: first the distinct parcel and footprint pairs, keyed on Regrid’s stable ll_uuid, then one row per parcel for its use code, acreage and assessed value. A footprint that crosses a parcel line counts once in the total and once in each use it touches, so the rows sum slightly above the total.

Show the code
-- exposed holds the footprints from either screen; parcels is Regrid's sample with the same bbox filter as bld
WITH pairs AS (
  SELECT DISTINCT p.ll_uuid, p.usedesc, e.id AS bid
  FROM parcels p JOIN exposed e ON ST_Intersects(p.geometry, e.geom)
),
parcel AS (  -- one row per parcel, so acres and value are counted once
  SELECT ll_uuid, first(usedesc) AS usedesc, first(ll_gisacre) AS acres, first(parval) AS parval
  FROM parcels WHERE ll_uuid IN (SELECT ll_uuid FROM pairs) GROUP BY ll_uuid
),
by_use AS (
  SELECT usedesc, count(*) AS parcels, round(sum(acres)) AS acres, round(sum(parval) / 1e6, 1) AS assessed_musd
  FROM parcel GROUP BY usedesc
),
bld_use AS (SELECT usedesc, count(distinct bid) AS buildings FROM pairs GROUP BY usedesc)
SELECT u.usedesc, u.parcels, b.buildings, u.acres, u.assessed_musd
FROM by_use u JOIN bld_use b USING (usedesc) ORDER BY u.parcels DESC;

Inside or within 500 m (1,640 ft) of a high-hazard basin: 5,934 parcels, 7,709 footprints

Use Parcels Buildings Acres Assessed value
Residential single 5,686 7,157 2,910 $4.69 B
Residential two units 72 186 34 $51 M
Government parcel 67 175 944 $4 M
Residential three units 29 86 14 $24 M
Utility pumping plants 15 33 25 $0.1 M
Apartments, five or more 14 82 10 $13 M
Miscellaneous 10 48 3,383 $0
Motion picture, radio, TV 8 52 868 $12 M

Below the scar’s lowest point, 223 m (732 ft): 10,513 parcels, 15,227 footprints

Use Parcels Buildings Acres Assessed value
Residential single 9,032 12,791 3,013 $8.49 B
Residential two units 373 988 77 $210 M
Apartments, five or more 172 498 72 $499 M
Commercial stores 129 187 50 $222 M
Commercial office buildings 105 184 62 $511 M
Residential three units 86 233 17 $56 M
Light manufacturing 84 123 27 $119 M
Residential four units 81 210 18 $66 M

The 7,713 footprints inside or within 500 m (1,640 ft) of a high-hazard basin sit on 5,934 parcels with a combined assessed value of $4.8 billion. Single-family homes account for $4.7 billion of it. The downhill screen is larger: the 15,233 footprints below the scar’s lowest point sit on 10,513 parcels assessed at $11.1 billion, and here the mix changes. Single-family homes are $8.5 billion of that total, and the remaining $2.6 billion is the commercial and apartment stock along the valley floor, led by $511 million in office buildings, $499 million in apartments and $222 million in stores. The two screens overlap, so the totals do not add. Every value comes from the assessor’s 2023 tax roll, so it describes the neighbourhood before the fire and before any rebuilding. Proposition 13 holds California assessed values well below market value, so these totals are a floor.

RS_Value reads the elevation under a point, so a cross-section is a line of generated points.

Show the code
-- a point every 0.00025 degrees, about 28 m, along 118.125° W, and the elevation under each
WITH pts AS (
  SELECT i, ST_SetSRID(ST_Point(-118.125, 34.265 - i * 0.00025), 4326) AS pt
  FROM (SELECT explode(sequence(0, 460)) AS i)
)
SELECT p.i, ST_Y(p.pt) AS lat, RS_Value(d.rast, p.pt, 1) AS elev
FROM pts p
JOIN wherobots_open_data.aster_gdem.v3_30m d ON ST_Intersects(d.footprint, p.pt)
ORDER BY p.i;
2026-09-24T20:53:30.305805 image/svg+xml Matplotlib v3.11.2, https://matplotlib.org/ 0 1 2 3 4 5 6 7 miles 0 1,000 2,000 3,000 4,000 5,000 feet 0 2 4 6 8 10 12 distance south along 118.125° W (km) 0 200 400 600 800 1000 1200 1400 1600 elevation (m) burn scar high-hazard basins 1,112 buildings within 60 m (197 ft) of the line north south
Terrain sampled every 28 m (92 ft) along 118.125° W, from the ridge north of the scar to the valley floor. Red bands mark the burn scar and the basins USGS rates high; cyan ticks mark buildings within 60 m (197 ft) of the line.

The line climbs to 1,577 m (5,174 ft) on the ridge. Then it crosses 2.4 km (1.5 mi) of high-hazard basin and 4.5 km (2.8 mi) of scar, down to 248 m (814 ft). 1,112 footprints lie within 60 m (197 ft) of it. The first of those buildings already sit inside the lower end of the high-hazard stretch.

Scope and limits of the exposure screen

These counts are an exposure screen. Neither they nor the USGS assessment predict where a debris flow travels or what it buries. USGS notes that most come within two years of a fire. So this winter falls near the end of that window, while the burned slopes are still recovering.

Three limits carry over. The elevation model is a surface model, so rooftops and treetops count as ground. Proximity is straight-line distance rather than flow path. Finally, the perimeter is simplified to 47 vertices, which changes its area by 0.4%. Flow routing would turn distance bands into runout paths. However, WherobotsDB has no built-in flow-direction function, so that takes a custom function over the rasters. It is the natural next step.

From one burn scar to the western United States

This screen covers one burn scar: the Eaton fire above Altadena. The same queries extend to every recent burn scar in California, and across the western United States, where USGS publishes debris-flow hazard assessments after major fires. Overture buildings and roads and the ASTER elevation model in wherobots_open_data already cover that whole region. Each new fire adds a perimeter and a set of basins to the same joins, once the study-area bounding box and UTM zone are updated to match. Governor Newsom declared the emergency ahead of El Niño rain this winter. The open question for every recently burned community is how many homes and roads sit below a high-hazard basin when that rain arrives.

One engine processed buildings, roads, elevation, and USGS hazard polygons, as vector and raster, with every file left where it sits. An AI agent grounded only in text, documents, databases, and the internet holds none of those layers, so it cannot say what sits below a basin. WherobotsDB supplies that physical-world context by joining them in place.

Imagery: Copernicus Sentinel data 2026. Elevation: ASTER GDEM is a product of METI and NASA. Buildings and roads: Overture Maps Foundation. Parcels: Regrid parcel sample. Fire perimeter: CAL FIRE FRAP, CC BY. Hazard basins: USGS Landslide Hazards Program, public domain.

Key takeaways

  • A raster vector join matches raster pixels to vector geometry by location. It is the case of a spatial join where one side is raster (elevation) and the other is vector (building and road geometry).
  • WherobotsDB runs both joins in SQL, reading Overture buildings and roads, the ASTER GDEM elevation model, Regrid parcels and the USGS hazard basins in place, as vector and raster, with no data movement.
  • 7,713 building footprints sit inside the 42 Eaton high-hazard basins or within 500 m (1,640 ft). 696 sit inside a basin. 16,748 sit within 1 km (0.6 mi).
  • 388 km (241 mi) of road and path sit in the 16.8 km² (6.5 sq mi) of ground below the scar’s lowest point. 305 km (190 mi) is drivable and 24 km (15 mi) is motorway.
  • Joined to Regrid parcels, the footprints near the basins sit on 5,934 parcels assessed at $4.8 billion on the 2023 tax roll.
  • A bounding-box filter before the spatial test cut the first building count to 22.8 seconds. Without it, the run was cancelled at 7% after three minutes.
  • The same queries extend to every recent burn scar in California and across the western United States, where USGS publishes debris-flow hazard assessments after major fires.
Get Started with Wherobots

Measuring the Strait of Hormuz shutdown with Sentinel-2, WherobotsDB and Overture Maps

Iran closed the Strait of Hormuz to commercial shipping on 28 February 2026. To measure what stopped moving, WherobotsDB read 719 Sentinel-2 scenes, 151 GB in total, straight from the public archive and fetched only about 1.5 GB covering three areas of interest. A near-infrared threshold found the hulls with no trained model, and a join against Overture Maps land polygons removed rocks and coastline. Traffic through the surveyed corridor fell 95% while loading and anchorage traffic held at 2025 levels, and the whole analysis cost about $63.

Sentinel-2 imagery is free and covers the planet every five days. Pointing the same job at another chokepoint takes two edited arguments.

What the satellites saw

We counted vessel hulls in Sentinel-2 imagery over three areas from January 2025 to August 2026 using a pixel-based reflectance method, while accounting for the sun’s angle over time:

area 2025 2026 change
Hormuz transit corridor 10.3 vessels 0.5 −95%
Fujairah anchorage, outside the strait 63.5 70.0 +10%
Kharg Island export terminal, inside the Gulf 7.3 9.9 +35%

Those figures are per satellite pass, averaged over the passes clear enough to cover the whole area.

We used imagery because the usual source cannot be trusted here. Ship traffic is normally tracked through AIS, which is the transponder every large vessel carries. In this crisis those transponders are being switched off or falsified. Near Fujairah and Khor Fakkan, roughly 470 vessels broadcast garbled or impossible positions inside a single 24-hour window. Windward counted 146 of 167 vessels in the strait area running dark on 5 May, and IMF PortWatch warns that its own numbers understate traffic. The optical imagery provides a signal (the hull) that is always on.

Vessel detections per satellite pass across the three areas, January 2025 to August 2026.

Vessel detections per satellite pass across the three areas, January 2025 to August 2026. The corridor line falls to near zero after the closure while the anchorage and the loading terminal return to 2025 levels.

How to count ships from space

Water absorbs almost all near-infrared light, so in Sentinel-2’s B08 band the sea is nearly black. Painted steel reflects it strongly. Over the Fujairah anchorage on August 7th, 2026 the water sat at 0.09 reflectance and the brightest pixel in the area read 0.87, so a vessel stands out by a wide margin. This means you don’t need a machine-learning model to identify a ship.

Wherobots queries the Sentinel-2 files directly in the public archive. The raster data source opens each scene in place and fetches only the necessary pixels.

The code below is an excerpt from the job script but the whole pipeline is available as a runnable notebook that opens in Wherobots Cloud, with every constant in one cell so you can point it at your own area of interest.

Show the code
AOI = ("POLYGON((56.42 25.10, 56.62 25.10, 56.62 25.30, "
       "56.42 25.30, 56.42 25.10))")
AOI_SQL = f"ST_SetSRID(ST_GeomFromWKT('{AOI}'), 4326)"

(sedona.read.format("raster")
    .option("tileWidth", "512").option("tileHeight", "512")
    .option("autoRescale", "true")      # off by default: without it you get raw DN
    .load(paths)
    .createOrReplaceTempView("tiles"))

# Background statistics on water only. The median centre ignores a bright
# minority of pixels. Pooling the per-tile standard deviations by their median
# discards a tile spoiled by cloud or land, but bright pixels inside a tile
# still inflate that tile's spread, so a crowded anchorage lifts the cutoff
# somewhat. That bias is conservative and falls on both years alike.
r = sedona.sql(f"""
    WITH s AS (
        SELECT zs.median AS med, zs.stddev AS sd
        FROM (SELECT RS_ZonalStatsAll(rast, {WATER_G}) AS zs FROM tiles)
        WHERE zs.count > 0
    )
    SELECT percentile_approx(med, 0.5) AS med, percentile_approx(sd, 0.5) AS sd
    FROM s
""").collect()[0]

threshold = r["med"] + max(SIGMA_K * r["sd"], 0.03)
2026-08-07  cov=100.0% cloud= 2.67%  med=0.0915 sd=0.0097 thr=0.1687  \
brightpx=   5636  vessels=  84  >=200m= 39

RS_ZonalStatsAll reports statistics for the pixels inside a shape. Using the median prevents a small number of bright pixels from raising the threshold; an average would be pulled upward by every ship in the frame. Adding a multiple of the spread gives a cutoff for that scene. The spread is pooled the same way, by taking the median of each tile’s standard deviation, which discards a tile spoiled by clouds. It is only a partial defense: ships inside a tile raise that tile’s spread, and with it the cutoff, so the count runs slightly conservative in a crowded anchorage. The sea is brighter in July than in January, so the cutoff has to move with it, which we account for in the analysis.

A short Python function applies that cutoff, and RS_Polygonize traces the outline of every bright patch.

Show the code
@sedona_vectorized_udf(return_type=RasterType())
def threshold_udf(rast: SedonaRaster) -> SedonaRaster:
    a = rast.as_numpy_masked()[0]
    out = np.zeros(a.shape, dtype=np.float64)
    with np.errstate(invalid="ignore"):
        out[np.isfinite(a) & (a >= threshold)] = 1.0
    return rast.with_bands(out[np.newaxis])


# with_bands() needs real pixels; the reader returns out-db references.
(sedona.sql("SELECT RS_AsInDB(rast) AS rast FROM tiles")
    .withColumn("mask", threshold_udf("rast"))
    .createOrReplaceTempView("masked"))

# RS_Polygonize returns every connected region in ONE array, so running it on a
# whole scene OOMs an executor -- per tile it is cheap and parallel.
sedona.sql(f"""
    SELECT t.p.geom AS g
    FROM masked LATERAL VIEW explode(RS_Polygonize(mask, 1)) t AS p
    WHERE t.p.value = 1.0
      AND ST_Area(t.p.geom) BETWEEN 2000 AND 200000
""").createOrReplaceTempView("raw_blobs")


# A vessel lying on a tile seam is polygonized twice, once either side of the
# cut. Union everything, then split it back apart: the halves become one
# polygon, while vessels that never touched stay separate. The area floor above
# runs before this merge, which has a cost the last section spells out.
sedona.sql("""
    WITH dissolved AS (
        SELECT explode(ST_Dump(ST_Union_Aggr(ST_MakeValid(g)))) AS g
        FROM raw_blobs
    ),
    sized AS (
        SELECT g, ST_Area(g) AS area_m2,
               2 * ST_MinimumBoundingRadius(g).radius AS length_m,
               ST_MinimumWidth(g) AS width_m
        FROM dissolved
        WHERE ST_Area(g) BETWEEN 2000 AND 200000
    )
    SELECT g AS geometry, area_m2, length_m, width_m
    FROM sized
    WHERE width_m >= 20
      AND length_m / width_m BETWEEN 2 AND 15
""").createOrReplaceTempView("candidates")

Sentinel-2 true color over the Fujairah anchorage on August 7th, 2026.

Sentinel-2 true color over the Fujairah anchorage on August 7th, 2026. A detected 280 m hull covers about 12,600 m², which is roughly two football pitches and matches the deck of a Suezmax tanker.

Ruling out rocks, land and glint

Rocky islets also reflect near-infrared light. The Quoin Islands sit directly in the shipping lane, and in an early run three of our first four detections turned out to be those rocks.

Wherobots holds imagery and map data in one engine, so checking detections against an independent source takes one query. Overture Maps publishes global land polygons. They already sit in the Wherobots open data catalog, and one join drops any detection that touches land.

Show the code
sedona.sql(f"""
    SELECT ST_Buffer(ST_Transform(geometry, 'EPSG:4326', 'EPSG:{epsg}'), 60) AS g
    FROM wherobots_open_data.overture_maps_foundation.base_land
    WHERE ST_Intersects(geometry, {AOI_SQL})
""").createOrReplaceTempView("land")

detections = sedona.sql("""
    SELECT d.geometry, d.area_m2, d.length_m
    FROM candidates d
    LEFT ANTI JOIN land l ON ST_Intersects(d.geometry, l.g)
""")

Two further checks run alongside it. Sun glint can brighten patches of open water, so we keep only detections shaped like ships, which filters out glint using the near-infrared band alone. A hull is typically four to eight times longer than it is wide and a glint patch is round, so the filter accepts anything between 2:1 and 15:1 and at least 20 m across. Those bounds are deliberately loose, wide enough that an unusual hull still passes while a round blob cannot. And a satellite pass sometimes covers only part of an area, so any date where less than 95% of the area was photographed is dropped. Counting a partly covered area would look like a decline in traffic that never happened. In practice nothing landed in between: every date behind the figures came in at 100% coverage, and the rest were dropped outright.

A study fusing Sentinel-1 radar with AIS measured a 97% decline across the two weeks either side of the closure. We measured 95% from optical imagery over a longer window, with no AIS involved.

Oversized shapes and the size bound

About one detection in forty measures longer than 400 m, a length no ship afloat reaches. The longest spans 1,310 m. These shapes cluster by date: at Fujairah, 15 of the 21 fall on a single date, and in the corridor, 13 of 24 fall on two, which fits cloud or sun glint on those passes. At Kharg, a shape about 1,120 m long recurs at the same spot on five dates, which points to a fixed terminal structure missing from the land polygons. An upper bound alongside the existing area floor removes all of them:

WHERE length_m BETWEEN 50 AND 400

The correction tightens the result. The corridor returned nine detections on 18 March 2026, one of the two clustered dates, and five of those exceed 400 m. Four hulls survive the bound, so the corridor reads emptier after the closure.

Every vessel we found

The job writes its detections as GeoParquet and then as PMTiles, a single file a browser can read directly from cloud storage without a tile server. Each month becomes its own layer, so a before-and-after comparison means publishing the two months that matter.

Show the code
from wherobots import vtiles

feats = sedona.sql("""
    SELECT geometry, aoi, obs_date, length_m, area_m2,
           date_format(to_date(obs_date), 'yyyy-MM') AS layer
    FROM det
""")

vtiles.generate_pmtiles(feats, f"{base}/hormuz_monthly.pmtiles")

The three maps below show one area each, with the 2025 month in blue and the 2026 month in red. Each caption gives the month’s total and a per-pass rate, which divides that total by the number of usable passes.

The corridor. May 2025 in blue against May 2026 in red. 46 detections across four clear passes in 2025, then two across one pass: about 11 vessels a pass, down to two. Open fullscreen.

The anchorage outside. April 2025 in blue against April 2026 in red. 200 detections over three passes, then 122 over two, which is roughly 67 a pass and then 61. April appears here because no May 2026 pass covered the anchorage. Open fullscreen.

The loading terminal inside the Gulf, May 2025 in blue against May 2026 in red. Loading held: 22 detections over two passes in 2025 and 29 over three in 2026, or 11 a pass then about 10. Open fullscreen.

All three areas at once, with every month switched on: 3,117 detections in 20 layers, one per month from January 2025 to August 2026. Kharg sits to the north-west, the strait and Fujairah to the south-east. Step through the months in the sidebar to see the corridor empty while the anchorage and the terminal keep their traffic. Open fullscreen.

Compute cost and data read

719 Sentinel-2 scenes cross these three areas over 20 months, which is 151 GB of imagery. The analysis read about 1.5 GB of that, because RS_Clip fetches only the parts of each file that overlap the three areas. The scenes stay in the public archive throughout.

The 20-month run took 26.8 minutes on a MEDIUM runtime, roughly 25 Spatial Units at $1.50 each in us-west-2, or about $37. Widening the same job to the whole Gulf of Oman and lower Persian Gulf, 368,875 km² at full coverage, added $23 and seven minutes of wall clock. Calibration and tiling took it to about $63. Wherobots Cloud reports the exact figure for every run in Workload History.

The coordinates and date range are parameters, so pointing the job at another chokepoint is an edit to two arguments. The notebook has them in its configuration cell, alongside the size and shape limits. Sentinel-2 passes overhead every five days, and a scheduled run keeps the count current independent of AIS broadcasts.

The surveyed corridor is 25 km wide within a strait roughly 40 km across, so the 95% decline describes that corridor. Whether some vessels shifted to routes outside it cannot be settled from these three areas. A second limit sits in the detector. The 2,000 m² area floor runs before the seam merge, so a small hull lying on a tile boundary can split into two pieces that each fall under the floor and go uncounted. A hull touches a seam on roughly 2% of passes, the loss bites only near the size floor, and it applies to every date equally, so the year on year comparisons survive it. The downloadable notebook applies the area floor after the merge instead.

Get Started with Wherobots

How to score every building in a state for catastrophe risk: an exploration project

Part 1 of a series: What’s possible?

I didn’t start this project to build a real risk model, not one that can be used by an insurer tomorrow. But it is a workflow that an insurer can put into practice to create their own risk scores with Wherobots and their own expertise. The idea was simple: what can be done in a day with Wherobots MCP for Claude and the VSCode plugin? It turns out quite a lot, and the barrier to entry is a Claude (or other coding agent) Account and VS Code running on a laptop with access to a Wherobots pro tier connected.

Total costs across tokens and data processing was about $300. This example looks at the Insurance industry and scoring risk on a property and building level. I’ll be following up with additional examples on Drone Delivery hub / nest site selection as well as communication services network connectivity in the coming weeks.

Lets start with the concept.

Build a Risk Explorer Data Pipeline with AWS & Wherobots

What does an insurer need to understand when evaluating the risk of a location?

Insurance works on a basic idea: what is the risk at a given location, and how should it be priced? For a property and casualty carrier that question repeats across every building and property asset in its portfolio. The data to answer it, in many cases, already exists spread across wildfire rasters, hail radar, flood layers, parcels, and road networks. Getting it into one scored, queryable, mappable form, updated on a recurring schedule is usually the hard part, because handling these data types is unsupported and often impossible in other compute environments.

This post walks that process end to end: every Overture building in Colorado, 2,771,126 of them, joined against the Parcels they sit upon from Regrid, and scored across five perils, from a statewide view down to a single rooftop. The data processing engine used is WherobotsDB on Wherobots Cloud in AWS. Our team at Wherobots maintains Apache Sedona, and WherobotsDB is 100% code compatible the OSS, but fully managed with more spatial functions and capabilities, grater performance, and enterprise support options. Three capabilities carry this data pipeline: spatial SQL over raster and vector datasets combined, a spatial operation that prunes on reads time, and PMTiles that WherobotsDB can deliver to enable a snappy browser experience on MapLibre.

The finished map is live on the Wherobots Website here. You can play with it inline below:

The Colorado catastrophe-risk explorer, built with WherobotsDB, Claude Opus 5 & VSCode via agentic app development.

The data fed into the application layer

Seven sources feed the app and the data pipeline:

LayerSource
Building footprintsOverture Maps
Wildfire hazardUSFS Wildfire Risk to Communities (30 m raster)
HailNOAA radar, 403M observations since 2016
FloodFEMA National Flood Hazard Layer
Tornado / windFEMA National Risk Index (tract)
Parcels, land useRegrid
Fire-station accessOverture places (road-network distance computed in the pipeline)

Two structural decisions:

  • A count is a floor. No measurement means no record, so a building with no hail observation is not a building at zero hail risk. Every count reads as “at least“.
  • The ranking: by building count, not by dollars. Assessor value coverage runs near 63% and is county dependent. El Paso, the largest county at 290,630 buildings, with $0 reported by the county. A dollar ranking would identify who reports property values, not where actual risk value is identified. A P&C insurer would have a clear understanding of building / parcel / asset value, so this diverts from what would be the likely standard they would want. These values could easily be added to this data pipeline, however.

The data pipeline: a medallion architecture for catastrophe risk scoring

The pipeline is a medallion architecture, and every layer is an Iceberg catalog table you can query on its own.

Bronze, raw as it lands, one table per source:

TableWhat lands in it
overture_buildingsOverture footprints (2.77M)
noaa_hailNOAA radar hail, 403M observations since 2016
usfs_wildfireUSFS Wildfire Risk to Communities, 30 m raster (COG)
fema_nfhlFEMA National Flood Hazard Layer (flood zones)
fema_nriFEMA National Risk Index, tract (tornado, wind)
regrid_parcelsRegrid parcels: reported parcel value, land use
overture_placesOverture fire-station locations

Silver, conformed: clipped to the Colorado boundary, coordinate systems reconciled, geometries validated: buildings, hail_grid, flood_zones, parcels, fire_stations, nri_tracts.

Gold, scored and tile-ready: building_perils (five peril scores per building), hex_rollup (H3 exposure and composite), parcels_scored.

Update frequency. Hail accumulation and Regrid parcels refresh monthly, so a monthly run keeps the map current. USFS wildfire and the FEMA National Risk Index refresh annually. The pipeline is defined with the Wherobots Python SDK and scheduled with Apache Airflow, one run per medallion stage.

Reading and clipping at scale with spatial predicate pushdown

The buildings layer is continental but the analysis is for Colorado. A spatial predicate filter pushdown on the read keeps makes it easy and cheap to run that state only:

-- Silver: every Colorado building footprint, clipped with a pushed-down spatial predicate
CREATE TABLE org_catalog.co_pc_risk_silver.buildings AS
SELECT b.id, b.geometry
FROM wherobots_open_data.overture_maps_foundation.buildings_building b
JOIN org_catalog.co_pc_risk_bronze.co_boundary aoi
  ON ST_Intersects(b.geometry, aoi.geometry);

WherobotsDB pushes ST_Intersects down to the GeoParquet files, reads each file’s bounding-box metadata, and skips the files that fall outside Colorado. Because WherobotsDB can easily index a dataset by proximity using say a Hilbert Curve index, this makes this type of operation even easier.

Fusing raster and vector in one query

This is a core WherobotsDB capability that this and many other use cases leverage. Wildfire hazard is a 30 m raster. Hail is millions of radar points. Flood is polygons. Buildings are polygons. One set-based query samples and joins all of them, per building:

-- Gold: score each building against the wildfire raster and the hazard vectors.
WITH wf AS (
  SELECT b.id, MAX(RS_ZonalStats(w.rast, b.geometry, 'max')) AS wildfire_score
  FROM org_catalog.co_pc_risk_silver.buildings     b
  JOIN org_catalog.co_pc_risk_silver.wildfire       w ON RS_Intersects(w.rast, b.geometry)
  GROUP BY b.id
),
hail AS (
  SELECT b.id, COUNT(*)  AS hail_events,
         MAX(h.size_in)  AS max_hail_in
  FROM org_catalog.co_pc_risk_silver.buildings     b
  LEFT JOIN org_catalog.co_pc_risk_silver.hail_obs h ON ST_Intersects(b.geometry, h.geometry)
  GROUP BY b.id
),
flood AS (
  SELECT b.id, ANY_VALUE(f.zone) AS flood_zone
  FROM org_catalog.co_pc_risk_silver.buildings       b
  LEFT JOIN org_catalog.co_pc_risk_silver.flood_zones f ON ST_Intersects(b.geometry, f.geometry)
  GROUP BY b.id
)
SELECT
  b.id,
  b.geometry,
  wf.wildfire_score,
  hail.hail_events,
  hail.max_hail_in,
  flood.flood_zone
FROM org_catalog.co_pc_risk_silver.buildings b
LEFT JOIN wf    ON b.id = wf.id
LEFT JOIN hail  ON b.id = hail.id
LEFT JOIN flood ON b.id = flood.id;

RS_ZonalStats reads the raster values under each footprint and returns key statistics. Each peril is computed in its own CTE so the joins stay one-to-many and the counts are exact. WherobotsDB executes RS_Intersects and ST_Intersects as spatial range joins, so the raster-to-building and point-to-building matches avoid a cross product. The run scores the full state in just a few minutes on one WherobotsDB medium runtime.

Five catastrophe risk scores per building

The fused query gives each building a raw hazard value per peril. Scoring turns those into five small integers on one row, so a building is one comparable object across every hazard. Each peril is scored on its own scale and the five sum to a 0-to-7 composite:

PerilRangeWhat sets the top score
Wildfire0-2USFS hazard sampled under the footprint, banded high / moderate / low
Hail0-2Largest radar-detected hail size over the building: 2 at 2 inches or greater
Flood0-1Inside a FEMA Special Flood Hazard Area
Wind / tornado0-1FEMA National Risk Index rating for the tract
Access0-1Greater than five road miles of the nearest fire station

The perils are scored individually. A single blended number would reads like a hazard map while hail dominates the sum, because hail is Colorado’s most costly regular peril. Keeping five columns lets the map user and the copilot switch to one peril at a time and understand them specifically.

The assignment is set-based, one CASE per peril over the fused hazard values:

-- Gold: turn raw hazard values into five 0-based peril scores per building
SELECT
  id,
  geometry,
  -- wildfire: USFS hazard under the footprint, banded 0 / 1 / 2
  CASE WHEN wildfire_hazard >= hi_break THEN 2
       WHEN wildfire_hazard >= lo_break THEN 1 ELSE 0 END               AS wf_score,
  -- hail: largest radar-detected hail size over the building, in inches
  CASE WHEN max_hail_in >= 2.0 THEN 2
       WHEN max_hail_in >= 1.0 THEN 1 ELSE 0 END                        AS hail_score,
  -- flood: inside a FEMA Special Flood Hazard Area
  CASE WHEN in_sfha THEN 1 ELSE 0 END                                   AS flood_score,
  -- wind / tornado: FEMA National Risk Index rating for the tract
  CASE WHEN nri_wind_elevated THEN 1 ELSE 0 END                         AS wind_score,
  -- access: beyond five road miles (8047 m) of the nearest fire station
  CASE WHEN road_m IS NULL OR road_m > 8047 THEN 1 ELSE 0 END           AS access_score,
  -- composite: the five summed, domain 0-7
  ( CASE WHEN wildfire_hazard >= hi_break THEN 2
         WHEN wildfire_hazard >= lo_break THEN 1 ELSE 0 END
  + CASE WHEN max_hail_in >= 2.0 THEN 2
         WHEN max_hail_in >= 1.0 THEN 1 ELSE 0 END
  + CASE WHEN in_sfha THEN 1 ELSE 0 END
  + CASE WHEN nri_wind_elevated THEN 1 ELSE 0 END
  + CASE WHEN road_m IS NULL OR road_m > 8047 THEN 1 ELSE 0 END )       AS composite
FROM org_catalog.co_pc_risk_gold.building_hazards;

Two of these bands are exposed in a way worth highlighting.

Wildfire and hail are measured under each footprint; wind and flood are inherited. wf_score reads the USFS raster at the building, and hail_score reads the radar accumulation over it. wind_score comes from a FEMA National Risk Index rating at census-tract resolution, and flood_score from a FEMA zone polygon. A tract or a zone is far coarser than a rooftop, so those two scores are derived from an area where the detections exist, and that coarser grain is labeled in the schema, not intended to represent as a per-structure reading.

On access, a NULL distance means furthest, not missing. The access score flags every building beyond five road miles of a fire station. The nearest-station search runs to a 25 km radius; a building with no station inside that radius has a NULL distance and is the most remote in the state, so the rule scores NULL as 1. Writing road_m > 8047 alone drops exactly the worst-access cohort. That is why the score, not the raw distance, drives the map.

Run over all 2.77M buildings, the top wildfire band holds 307,679 buildings and the 2 in-and-up hail band holds 790,889. Both counts are floors: a building with no observation for a peril scores 0 for it, and reads as at least that exposed.

The accumulation view

The per-building table has fine grained details. A reinsurer often is concerned with concentration risk, and it’s easier to visualize when aggregated, so its helpful to roll the same scores up to an H3 grid:

-- Gold: roll per-building scores up to H3 for the accumulation view
SELECT
  ST_H3CellIDs(geometry, 7, false)[1] AS h3,
  COUNT(*)                            AS buildings,
  SUM(wildfire_score)                 AS wildfire_exposure,
  AVG(composite)                      AS mean_composite
FROM org_catalog.co_pc_risk_gold.building_perils
GROUP BY ST_H3CellIDs(geometry, 7, false)[1];

The same gold tables drive both the statewide hex view and the street-level building view. Nothing recomputes between zoom levels.

From a gold table to a map anyone can interact iwth

WherobotsDB builds the PMTiles as vector tiles and ships them to object storage, so a hand-written MapLibre GL JS page reads them by their S3 URL. No separate tiling tool, no tile server, no build step in the app.

# WherobotsDB builds and ships the PMTiles for the web app
from wherobots import WherobotsJob
WherobotsJob(script="s3://.../export_pmtiles.py", name="co-risk-export", runtime="small").submit()

The build-to-browser toolchain

The whole build was authored and shipped with one repeatable stack:

  • Build and version: Claude Code (Opus 5) inside VS Code, with the pipeline and the app committed to GitHub.
  • Compute and data: WherobotsDB on Wherobots Cloud runs the medallion pipeline; the Wherobots Python SDK defines each job and it is scheduled with Apache Airflow.
  • Tiles and hosting: WherobotsDB builds the PMTiles and ships them to Amazon S3; Vercel serves the static MapLibre GL JS page from its CDN.
  • Ask the map: the app includes a Claude copilot. Ask where is the worst wildfire exposure in El Paso County, and Claude switches the peril, recolors the map, and moves to show you the answer. It runs as a Vercel serverless function that calls the Anthropic API, with the analysis rules in its system prompt and the key held server-side.

What these catastrophe risk scores are, and what they are not

The five scores screen where to look. They are not a calibrated loss model, and nothing here is a determination of insurability. The bands, the floors, and the inherited-grain labels live in the pipeline and again in the copilot’s system prompt, so the map view and the copilot’s answers stay inside the same limits.

What the map surfaces

Hail is Colorado’s dominant peril, ahead of wildfire. In the top wildfire tier alone, 307,679 buildings screen as elevated: a starting list for where to focus. The access-to-services peril, powered by road-network distance to the nearest fire station, marks the insurability boundary, the cohort beyond a fire department’s reach. A P&C analyst can review all of it by toggling the map.

How to port this catastrophe risk scoring pipeline to any state or portfolio

The Colorado build is a template. Swap the boundary for your state or the whole country, swap the peril rasters and point clouds for the ones on your portfolio, and the pipeline holds: push the spatial predicate on the read, fuse raster and vector in one query, roll up to H3, let WherobotsDB build and ship the tiles down to the property parcel and building layer.

That is the point of an AI Context Engine for the Physical World: the physical-world data, prepared and scored at the scale a portfolio spans, in a form an analyst and an agent can both query.

Start on Wherobots Cloud, connect to the Wherobots Claude MCP, or open the Colorado risk explorer to see what I built, or check out the docs at docs.wherobots.com.

Get Started with Wherobots

Key takeaways

  • The whole build took a day on a laptop with Claude and VS Code. The barrier to entry is a Claude account, VS Code, and a Wherobots Professional tier account. Part 1 of a series showing what one person can build in a day on this stack.
  • The project builds a scoring workflow an insurer would put into practice, not a calibrated risk model. It is a workflow an insurer runs to create their own risk scores with Wherobots and their own expertise. The five scores screen where to look. They do not determine insurability.
  • The layers already exist. Getting them into one scored, queryable form is the hard part. The data spreads across wildfire rasters, hail radar, flood layers, parcels, and road networks. WherobotsDB does the fusing in one query: spatial SQL over raster and vector, spatial predicate pushdown at read, and PMTiles the engine builds and ships to the browser.
  • The Colorado build is a template for any state or portfolio, and drone delivery and telecom examples will come next in the series. Swap the boundary for your state and the peril rasters for the ones on your portfolio.

From the Spokane firestorm to all of Washington: real-time wildfire monitoring for under $50 a pass

On August 1, 2026, the most destructive fire event in Washington’s history swept into the Spokane area, driving 65,000 people from their homes. Wildfire response runs on one thing: knowing where the fire has burned and where it is growing. Every hour sooner that map exists, the better every decision downstream, from evacuation zones and containment lines to protecting homes, utilities, and insured assets.

The satellites already see every fire. What has been missing is real-time analysis: the moment a new pass lands in the archive, this pipeline turns it into a burn-severity map in 14 minutes, for about $20 of compute. One Python job on WherobotsDB, no downloads, no ingest, no GIS backlog. We built it on the Spokane firestorm, then pointed the same job at the entire state, and it found Washington’s other major fires (Sinlahekin, Modrite, Kaiser Canyon) by itself, while they were still burning. The pipeline keeps up with whatever imagery you feed it: the free Sentinel-2 constellation delivers a fresh statewide look every few days, and higher-cadence sources slot into the same job. Every number below comes from the production runs.

Burn severity of the Spokane firestorm: Sentinel-2 dNBR, August 6 2026

Burn severity of the Spokane firestorm: Sentinel-2 dNBR, August 6 2026

The technique is dNBR (differenced Normalized Burn Ratio), the standard used by USGS and the EU’s Copernicus Emergency Management Service for burn severity mapping. Healthy vegetation is bright in near-infrared (Sentinel-2 band B8A) and dark in shortwave-infrared (B12); burned ground flips that. So:

  • NBR = (B8A − B12) / (B8A + B12), per pixel, per scene
  • dNBR = NBRpre-fire − NBRpost-fire; big positive values mean severe burn

The input is 56 cloud-optimized GeoTIFFs (~2.6 GB) in the public Sentinel-2 archive on S3, and none of it is downloaded or ingested. WherobotsDB’s out-of-database rasters resolve lazily, so only the ~9% of pixels inside our area of interest ever cross the wire.

Which scenes detected the fire?

WherobotsDB’s STAC reader loads a STAC collection as a DataFrame, with spatial and temporal filter pushdown. We point it at Element84’s Earth Search catalog and filter to a bounding box covering all three fires, a pre-fire window (July 10–31) and a post-fire window (August 2 onward). Each STAC asset comes with a ready-to-use out-db raster column.

Show the code
AOI = "POLYGON((-117.75 47.55, -117.20 47.55, -117.20 47.95, -117.75 47.95, -117.75 47.55))"
AOI_SQL = f"ST_SetSRID(ST_GeomFromWKT('{AOI}'), 4326)"

raw = (
    sedona.read.format("stac")
    .option("itemsLimitMax", "1000")
    .load("https://earth-search.aws.element84.com/v1/collections/sentinel-2-c1-l2a")
)
raw.createOrReplaceTempView("stac_items")

scenes = sedona.sql(f"""
    SELECT id,
           element_at(split(id, '_'), 2) AS tile,
           datetime,
           CASE WHEN datetime <= TIMESTAMP '2026-07-31 23:59:59'
                THEN 'pre' ELSE 'post' END AS phase,
           `eo:cloud_cover` AS cloud_cover,
           assets['nir08'].rast AS nir,    -- B8A, 20 m
           assets['swir22'].rast AS swir   -- B12, 20 m
    FROM stac_items
    WHERE ST_Intersects(geometry, {AOI_SQL})
      AND datetime >= TIMESTAMP '2026-07-10 00:00:00'
      AND datetime <= TIMESTAMP '2026-08-07 23:59:59'
      AND (datetime <= TIMESTAMP '2026-07-31 23:59:59'
           OR datetime >= TIMESTAMP '2026-08-02 00:00:00')
""")
scenes.createOrReplaceTempView("scenes")
scenes.select("id", "tile", "datetime", "phase", "cloud_cover").orderBy("tile", "datetime").show(5)
+------------------------------+------+-----------------------+-----+-----------+
|id                            |tile  |datetime               |phase|cloud_cover|
+------------------------------+------+-----------------------+-----+-----------+
|S2C_T11TMN_20260712T185514_L2A|T11TMN|2026-07-12 19:01:16.775|pre  |99.997228  |
|S2B_T11TMN_20260714T184425_L2A|T11TMN|2026-07-14 18:51:18.916|pre  |88.490695  |
|S2A_T11TMN_20260714T190034_L2A|T11TMN|2026-07-14 19:01:32.559|pre  |89.065939  |
|S2B_T11TMN_20260717T185644_L2A|T11TMN|2026-07-17 19:01:16.249|pre  |12.527606  |
|S2C_T11TMN_20260719T184029_L2A|T11TMN|2026-07-19 18:51:20.68 |pre  |3.3E-4     |
+------------------------------+------+-----------------------+-----+-----------+
only showing top 5 rows

28 scenes match, across two MGRS tiles (T11TMN to the south, T11UMP to the north). With Sentinel-2A, -2B, and -2C all flying, Spokane is revisited every two to three days. That cadence is what lets the pipeline track a fire’s status over time.

How burned is a pixel?

Each scene’s B8A and B12 arrive as separate single-band rasters. We clip both to the AOI with RS_Clip, which keeps every downstream operation working on a ~4 M-pixel window instead of a full 30 M-pixel tile. The band math itself is a vectorized Python UDF: the function takes one SedonaRaster argument per raster column, reads pixels as NumPy arrays with nodata masked to NaN, and returns a new raster with with_bands, which keeps the CRS and transform intact (a follow-up RS_SetBandNoDataValue marks the sentinel for the statistics downstream). WherobotsDB feeds the UDF through Apache Arrow in batches, so plain NumPy runs at cluster scale, and everything in the SciPy ecosystem is available inside the function when an analysis needs it.

The raster reader already applies the COG’s raster:bands scale and offset, so pixel values arrive as surface reflectance (≈0–1). Use them directly; no DN conversion is needed. If an index map ever looks suspiciously flat, a one-second RS_Value probe at a known point tells you why.

Show the code
import numpy as np
from pyspark.sql.functions import col, expr
from sedona.spark.raster import SedonaRaster
from sedona.spark.sql.functions import sedona_vectorized_udf
from sedona.spark.sql.types import RasterType

NODATA = -9999.0

@sedona_vectorized_udf(return_type=RasterType())
def nbr_udf(nir: SedonaRaster, swir: SedonaRaster) -> SedonaRaster:
    """NBR = (B8A - B12) / (B8A + B12).

    as_numpy_masked() turns nodata into NaN, so invalid pixels ride through
    the arithmetic; values <= 0 (clip fill, dark pixels) join them.
    """
    n = nir.as_numpy_masked()[0]
    s = swir.as_numpy_masked()[0]
    with np.errstate(divide="ignore", invalid="ignore"):
        nbr = np.where((n <= 0) | (s <= 0), np.nan, (n - s) / (n + s))
    return nir.with_bands(np.where(np.isnan(nbr), NODATA, nbr)[np.newaxis])

clipped = sedona.sql(f"""
    SELECT id, tile, datetime, phase, cloud_cover,
           RS_Clip(nir, 1, {AOI_SQL}, false, 0.0) AS nir_aoi,
           RS_Clip(swir, 1, {AOI_SQL}, false, 0.0) AS swir_aoi
    FROM scenes
""").filter("nir_aoi IS NOT NULL AND swir_aoi IS NOT NULL")

nbr = clipped.select(
    "id", "tile", "datetime", "phase", "cloud_cover",
    nbr_udf(col("nir_aoi"), col("swir_aoi")).alias("nbr_raw"),
).withColumn(
    "nbr", expr(f"RS_SetBandNoDataValue(nbr_raw, {NODATA}d)")
).drop("nbr_raw")
nbr.persist()
nbr.createOrReplaceTempView("nbr")

The value <= 0 guard handles nodata, clip fill, and the offset-negative dark pixels (water, deep shadow) in one condition. From here, one RS_ZonalStatsAll per scene gives a mean-NBR time series over the AOI: a quick health check on the imagery now, and a recovery tracker later.

Show the code
timeseries = sedona.sql(f"""
    SELECT id, tile, date(datetime) AS date, phase, cloud_cover,
           zs.mean AS mean_nbr, zs.count AS valid_px
    FROM (SELECT *, RS_ZonalStatsAll(nbr, {AOI_SQL}) AS zs FROM nbr)
    ORDER BY tile, date
""").localCheckpoint()   # materialize the 28-row table and cut its lineage
timeseries.createOrReplaceTempView("nbr_ts")

Mean NBR per clear scene, July 17 to August 6, with ignition marked.

Mean NBR per clear scene, July 17 to August 6, with ignition marked. Eight clear scenes in three weeks: that revisit rate is what turns a one-off map into a monitor.

Which two scenes? (the boring part that matters)

dNBR needs one pre-fire and one post-fire scene per tile, and the naive choice (“latest scene on each side”) bit us: Sentinel-2 sometimes acquires two overlapping products on the same day, and the latest pre-fire scene for T11TMN was a partial swath covering only a third of the tile. The result was a nodata band right through the Old Trails burn scar.

The selection rule: (1) drop scenes with 20% cloud or more, (2) gate on near-full swath coverage using the zonal pixel counts we already computed, and (3) only then prefer recency. Two window functions express the whole rule in SQL: max(valid_px) OVER each tile and phase sets the coverage bar, and row_number() takes the best-ranked scene on each side. The localCheckpoint() above matters here; it hands the planner a small, static table for this self-join while the raster work stays lazy.

Show the code
pairs = sedona.sql("""
    WITH scored AS (
        SELECT tile, phase, id, date, valid_px,
               max(valid_px) OVER (PARTITION BY tile, phase) AS max_px
        FROM nbr_ts
        WHERE cloud_cover IS NULL OR cloud_cover < 20
    ),
    ranked AS (
        SELECT *, row_number() OVER (
            PARTITION BY tile, phase
            ORDER BY (valid_px >= 0.95 * max_px) DESC,
                     date DESC, valid_px DESC, id DESC) AS rn
        FROM scored
    )
    SELECT pre.tile,
           pre.id AS pre_id, pre.date AS pre_date,
           post.id AS post_id, post.date AS post_date
    FROM ranked pre
    JOIN ranked post
      ON pre.tile = post.tile
     AND pre.phase = 'pre' AND post.phase = 'post'
     AND pre.rn = 1 AND post.rn = 1
""")
pairs.createOrReplaceTempView("pairs")

if pairs.count() == 0:
    raise ValueError("no usable scene pairs: every candidate was cloud- or coverage-filtered")

@sedona_vectorized_udf(return_type=RasterType())
def dnbr_udf(pre: SedonaRaster, post: SedonaRaster) -> SedonaRaster:
    """dNBR = pre-fire NBR - post-fire NBR; NaN stays NaN through the subtraction."""
    diff = pre.as_numpy_masked()[0] - post.as_numpy_masked()[0]
    return pre.with_bands(np.where(np.isnan(diff), NODATA, diff)[np.newaxis])

dnbr = sedona.sql("""
    SELECT p.tile, p.pre_date, p.post_date,
           pre.nbr AS pre_nbr, post.nbr AS post_nbr
    FROM pairs p
    JOIN nbr pre  ON pre.id  = p.pre_id
    JOIN nbr post ON post.id = p.post_id
""").select("tile", "pre_date", "post_date",
            dnbr_udf(col("pre_nbr"), col("post_nbr")).alias("dnbr_raw"))
dnbr = dnbr.withColumn("dnbr", expr(f"RS_SetBandNoDataValue(dnbr_raw, {NODATA}d)")).drop("dnbr_raw")
dnbr.persist()
dnbr.createOrReplaceTempView("dnbr")

How bad, and over how many acres?

USGS thresholds turn dNBR into severity classes (low ≥ 0.10, moderate-low ≥ 0.27, moderate-high ≥ 0.44, high ≥ 0.66). One more UDF pass classifies the raster, and RS_CountValue turns class pixel counts into acres. At 20 m resolution each pixel is 400 m², about a tenth of an acre:

Show the code
@sedona_vectorized_udf(return_type=RasterType())
def classify_udf(dnbr: SedonaRaster) -> SedonaRaster:
    """USGS dNBR classes: 1 low, 2 moderate-low, 3 moderate-high, 4 high.

    NaN compares False against every threshold, so nodata lands in class 0.
    """
    v = dnbr.as_numpy_masked()[0]
    with np.errstate(invalid="ignore"):
        out = np.select(
            [v >= 0.66, v >= 0.44, v >= 0.27, v >= 0.10],
            [4, 3, 2, 1],
            default=0)
    return dnbr.with_bands(out.astype(np.float64)[np.newaxis])

classified = dnbr.withColumn("classified", classify_udf(col("dnbr")))
classified.createOrReplaceTempView("classified")

ACRES = 400.0 / 4046.8564224

severity = sedona.sql(f"""
    SELECT tile, pre_date, post_date,
           ROUND(RS_CountValue(arr, 1) * {ACRES}, 1) AS low_acres,
           ROUND(RS_CountValue(arr, 2) * {ACRES}, 1) AS mod_low_acres,
           ROUND(RS_CountValue(arr, 3) * {ACRES}, 1) AS mod_high_acres,
           ROUND(RS_CountValue(arr, 4) * {ACRES}, 1) AS high_acres
    FROM (
        SELECT tile, pre_date, post_date,
               RS_BandAsArray(classified, 1) AS arr
        FROM classified
    )
""")
severity.show(truncate=False)
+------+----------+----------+---------+-------------+--------------+----------+
|tile  |pre_date  |post_date |low_acres|mod_low_acres|mod_high_acres|high_acres|
+------+----------+----------+---------+-------------+--------------+----------+
|T11TMN|2026-07-29|2026-08-06|6017.8   |3335.0       |1788.9        |502.9     |
|T11UMP|2026-07-29|2026-08-06|4372.5   |1663.2       |677.6         |193.8     |
+------+----------+----------+---------+-------------+--------------+----------+

Five days after ignition, the burn reads like this (the two tiles overlap in the 47.7–47.9°N band where the fires sit, so the rows must not be summed):

Tile Pair Low Mod-low Mod-high High Moderate+
T11TMN (south: NW Spokane, Nine Mile, Airway Heights) Jul 29 → Aug 6 6,018 3,335 1,789 503 5,627
T11UMP (north: Suncrest, Mead) Jul 29 → Aug 6 4,373 1,663 678 194 2,535

The map at the top of this post is the dNBR raster classified with these thresholds. The job also writes it out as GeoTIFFs with RS_AsGeoTiff and the raster writer, so the same output drops straight into QGIS. The main scar straddles the Spokane River around Nine Mile Falls and the Indian Trail bluffs, with the high-severity core on the slopes above the river. The low class (0.10–0.27) is the noisiest; at these thresholds it also picks up harvest and irrigation changes in cropland, so the moderate-and-worse figure is the one to quote. Official incident reports put the combined fires at ~10,000 acres of perimeter, which is expected to exceed spectrally-burned pixel area.

Burned acres by severity class, five days after ignition, per Sentinel-2 tile.

Burned acres by severity class, five days after ignition, per Sentinel-2 tile.

The same statements, one hundred times the area

Nothing in the pipeline knows it is about Spokane; the AOI is a WKT string. We swapped the 30 km box for all of Washington plus the Idaho panhandle (42 Sentinel-2 tiles, 690 candidate scenes, three UTM zones) and let the engine do what engines do:

SCL-masked dNBR wall-clock: region scale is a parameter.

SCL-masked dNBR wall-clock: region scale is a parameter. RS_TileExplode splits each scene into 512-pixel sub-tiles, so thousands of small tasks fill the cluster. That pattern is what keeps the statewide run this fast.

457 detections in one run: the big ones are all named fires.

457 detections in one run: the big ones are all named fires. Dot area scales with detected acres (Jul 27 to Aug 6 change). Gray dashed dots are cropland-flagged by ESA WorldCover, 403 of 457; the 54 survivors are the fire candidates, and every news-confirmed fire is among them. The dashed box is the query AOI.

Explore every detection, live

The map below is the real output of the statewide run. Our production monitoring job, the same pipeline described above extended with burn-cluster detection and the cropland flag, ends by writing its detections as a single PMTiles archive with one call, vtiles.generate_pmtiles(detections, s3_path). A browser reads that file directly from S3 with HTTP range requests, no tile server, no export pipeline, rendered here in the Wherobots visualization tool. Click any polygon for its properties. If the viewer below is not working, try fullscreen mode here.

Did it work? Check the news

A detector is only as good as its false-positive rate, so we cross-checked the largest region-wide detections against news coverage of Washington’s August 2026 fire outbreak, the one that triggered the state’s first-ever “particularly dangerous situation” fire-weather warning.

Detection (Aug 6 imagery) Identity Cropland flag
69,022 ac near Tonasket Sinlahekin Fire: 141,411 ac by Aug 10; ours is the Jul 27 → Aug 6 growth slice clear: fire candidate
40,485 ac near Inchelium Modrite Fire: “over 40,000 acres” in the same window clear: fire candidate
Two clusters near Nespelem Kaiser Canyon Fire flanks: ignited Jul 16, before our baseline, so only fresh growth appears clear: fire candidate
4,512 ac northwest of Spokane The Spokane complex this post began with, rediscovered without being told it exists clear: fire candidate
46,947 ac in the Palouse No fire in the news; wheat harvest between the two dates flagged: cropland
11,073 ac Skagit delta · 6,219 ac Columbia Basin No fires in the news; farmland and irrigation change flagged: cropland

Every news-confirmed fire lands in the clear column; every no-news detection lands in the flagged column. At region scale the flag does serious work: 403 of 457 raw detections are cropland-majority, leaving 54 fire candidates from one extra join against ESA WorldCover, in the same engine that did everything else.

What didn’t happen here

Most remote-sensing pipelines spend their time and money on steps this analysis skipped:

  • No ingest. The STAC catalog was queried live and the COGs stayed where ESA/Element84 publish them. There is no “download the scenes” step, no staging bucket of copies to manage, and adding the next satellite pass to the analysis costs nothing but a date-range change.
  • No raster database to load. The out-db raster model means RS_Clip, the NBR UDF, and RS_ZonalStatsAll executed against windowed range-reads of the source files: ~2.6 GB of input resolved to only the ~9% of pixels inside the AOI.
  • Open formats on both ends. Results landed back in the lakehouse as GeoParquet and GeoTIFF. The severity table is queryable by any engine that reads Parquet, and the dNBR map opens directly in QGIS. Nothing is locked in a proprietary store.
  • One engine for rasters and vectors. The same SQL session can join these burn pixels against Iceberg/Havasu tables, like Overture Maps buildings in the Wherobots Open Data catalog, which is where this analysis goes next.

That combination (query-in-place over open cloud archives, spatial SQL over both raster and vector, and open outputs) is the lakehouse difference: the time from “a fire happened” to “a severity map exists” is measured in minutes of compute, not days of data wrangling.

From one-off analysis to standing watch

The analysis lives in one Python job script submitted with the Wherobots Airflow provider. New Sentinel-2 scenes land every two to three days, so a scheduled DAG keeps the severity map and the recovery time series current with zero manual steps:

run = WherobotsRunOperator(
    task_id="run_dnbr_job",
    name="spokane_fire_dnbr_{{ ds_nodash }}",
    region=Region.AWS_US_WEST_2,   # same region as the Sentinel-2 COGs
    runtime=Runtime.MEDIUM,
    run_python={"uri": f"{SCRIPT_S3}", "args": ["--post-end", "{{ ds }}"]},
    poll_logs=True,
)

End to end (STAC discovery, 28 NBR rasters, pair selection, dNBR, classification, acreage, Parquet + GeoTIFF outputs) takes about 14 minutes on a MEDIUM runtime and costs about $20: roughly 13 Spatial Units at $1.50 each in us-west-2. Wherobots Cloud reports the exact cost and Spatial Unit consumption of every run in Workload History, so the price of a standing watch is a line item you can read, per run, per day. The statewide watch is the same arithmetic: the Washington plus North Idaho detection run behind the live map above came to about $45 (30 Spatial Units on a LARGE runtime, 14 minutes for 42 tiles across three UTM zones).

Where to take it

  • Mask smoke and cloud per-pixel with the SCL band instead of scene-level metadata.
  • Vectorize the burn perimeter with RS_Polygonize on the classified raster.
  • Count affected structures by joining the perimeter against Overture Maps buildings, already hosted in the Wherobots Open Data catalog.
  • Track recovery: the same NBR time series that found the drop will show vegetation green-up over the coming seasons.

Everything here runs on any Wherobots organization. The imagery is public, and the code above is the entire method. If you want to try it on a fire (or flood, or storm) near you, create a Wherobots Cloud organization and point the STAC reader at your own AOI.

Key takeaways

  • On August 1, 2026, the Spokane firestorm drove 65,000 people from their homes. The pipeline turns a new Sentinel-2 pass into a burn-severity map in about 14 minutes for about $20 of compute: one Python job on WherobotsDB, with no downloads, no ingest, and no GIS backlog.
  • Input is 56 cloud-optimized GeoTIFFs (~2.6 GB) in the public Sentinel-2 archive on S3. WherobotsDB out-of-database rasters resolve lazily, so only about 9% of pixels inside the area of interest ever cross the wire. End to end on a MEDIUM runtime costs about $20 (roughly 13 Spatial Units at $1.50 each in us-west-2).
  • The same job pointed at Washington plus the Idaho panhandle (42 Sentinel-2 tiles, 690 candidate scenes, three UTM zones) finished in 14 minutes on a LARGE runtime for about $45 (30 Spatial Units). It produced 457 detections; 403 were cropland-majority via ESA WorldCover, leaving 54 fire candidates that included every news-confirmed fire in the window.
  • dNBR uses USGS thresholds (low ≥ 0.10, moderate-low ≥ 0.27, moderate-high ≥ 0.44, high ≥ 0.66). Five days after ignition, tile T11TMN showed 5,627 acres at moderate-and-worse; the low class also picks up harvest and irrigation change, so the post quotes moderate-and-worse rather than summing all classes.

How well does SAM3 detect building footprints? We asked the Wherobots Spatial AI Coding Assistant

In a recent post, we showed how easy it is to use RasterFlow and Meta’s Segment Anything 3 Model (SAM3) to detect features in the physical world. A single end-to-end pipeline built a 133 GB NAIP mosaic of Marion County, Oregon, ran SAM3 against it with text prompts spanning eight classes, and produced approximately one million detection polygons in a Wherobots table including roughly 312,000 building roofs.

That is an impressive result on its own. But once the inference job finished, the obvious next question was: are these detections any good? Specifically, how well do they agree with an independent reference dataset of building footprints?

The Overture Maps Foundation publishes a global buildings dataset that is freely available in the Wherobots Hub. If I could compare the SAM3 roof detections against Overture for the same county, I would have a first-pass evaluation of whether SAM3 is finding the right things in roughly the right places.

The catch: I am a product manager, not a data scientist, and I am not well-versed in the standard techniques used to evaluate the output of remote-sensing models. Intersection-over-union, recall and precision curves, confidence calibration, the right metric coordinate reference system… I knew the terms, but I had not performed an evaluation like this from scratch before.

That is exactly where the Wherobots Spatial AI Assistant comes in.

Upcoming Session: See an AI agent take plain-language question and orchestrating pipelines to end results using RasterFlow and the Wherobots MCP server.

Evaluating SAM3 on Aerial Imagery in Four Prompts

I opened a conversation with the assistant inside Claude, using the Wherobots MCP server. I described what I had: a fresh SAM3 detection table in org_catalog.sam3_marion_db.sam3_outputs, and a goal of comparing it to Overture buildings. The full session took four prompts.

Prompt 1: Describe the data.

“can you see SAM3 results in org_catalog.sam3_marion_db.sam3_outputs? can you tell me more about this dataset of SAM3 detections for Marion County OR?”

Within seconds, the assistant had walked the catalog, run summary statistics, and returned a complete profile: eight detection classes (layers), around one million total rows, 312k roofs, confidence scores ranging from 0.50 to 0.96, the table’s spatial extent, and the source mosaic. One of the queries it ran was a simple breakdown of the detections by class (layer):

SELECT layer, COUNT(*) AS n, AVG(bbox_score) AS avg_score
FROM org_catalog.sam3_marion_db.sam3_outputs
GROUP BY layer
ORDER BY n DESC

A sample of SAM3 roof detections (blue) over NAIP imagery in Marion County, Oregon. Each polygon is a segmentation mask, not a bounding box.

Prompt 2: Design the comparison.

“if I wanted to compare the buildings (roofs) detecting with SAM3 against the building footprints in the overture dataset in wherobots_open_data.overture_maps_foundation, how would I do that?”

The assistant designed a four-stage approach: clip both datasets to a Marion County area of interest; spatial-join them on intersection; compute intersection-over-union per pair; and aggregate to recall, precision, and calibration metrics. It also surfaced a set of caveats:

  • Roofs are not footprints, since overhangs and occlusion mean the shapes will never match exactly.
  • Overture is not ground truth in rural areas and is likely to miss buildings.
  • The 0.5 confidence cutoff was already baked into the SAM3 outputs, which limits any precision-recall analysis to the upper half of the confidence range.
  • Area math must be performed in UTM zone 10N rather than EPSG:4326.

These considerations gave me confidence that the analysis was well thought through and likely accurate.

Prompt 3: Use the right area of interest.

Rather than a rough bounding box, I wanted the comparison clipped to the actual Marion County admin boundary, fetched from a trustworthy source.

“can you use wkls to get the official admin boundary from Overture for Marion County, like this: gdf = gpd.read_file(wkls['us']['or']['Marion County'].geojson())”

The assistant rebuilt the join strategy around the admin polygon generated using wkls. It pulled the boundary as a single-row Spark view that could be broadcast into the spatial joins, and combined an Iceberg bounding-box prefilter on Overture with an exact ST_Intersects predicate against the real Marion County shape. The generated code was straightforward:

import wkls
import geopandas as gpd

gdf = gpd.read_file(wkls['us']['or']['Marion County'].geojson())
aoi_geom = gdf.geometry.iloc[0]
aoi_wkt  = aoi_geom.wkt

sedona.sql(f"""
  CREATE OR REPLACE TEMP VIEW aoi AS
  SELECT ST_GeomFromWKT('{aoi_wkt}') AS geom
""")

Prompt 4: Let’s do the analysis in a notebook.

“can you create this analysis in a Jupyter notebook that I can run with Wherobots?”

The assistant returned a 24-cell notebook covering everything from SedonaContext setup through the final visualization. I ran it in VS Code using a Wherobots cloud runtime. The rest of this post walks through the code and the results.

Comparing SAM3 Building Detections to Overture Footprints

Here’s the notebook:

sam3_vs_overture_marion.ipynb

The notebook has four sections:

Setup and constants. Setting up imports, initializing the SedonaContext, defining the target CRS (EPSG:32610, UTM zone 10N), IoU thresholds, and table names. It’s worth noting out that the assistant chose UTM zone 10N automatically (the right metric CRS for western Oregon) without me having to ask.

Fetch the AOI from wkls. The notebook calls wkls['us']['or']['Marion County'].geojson(), reads the result into a GeoPandas frame, extracts the polygon’s WKT and bounding box, and registers a one-row aoi Spark view. It then renders the boundary on a SedonaKepler map so I could visually confirm I had the right AOI before running anything expensive.

Sanity counts. Two COUNT(*) queries over the SAM3 and Overture tables, both clipped to the admin boundary:

  • SAM3 roofs inside Marion County: 261,414 (the full dataset has 312k; the remainder fell outside the official admin polygon).
  • Overture buildings inside Marion County: 146,642.

SAM3 finds notably more candidate roof shapes than Overture has buildings.

Filtered AOI views. Two temporary views, sam3_roofs and overture_bldgs, each with the lon/lat geometry and a UTM-projected geometry. The Overture view uses a two-stage filter: a bounding-box prefilter that takes advantage of Iceberg column statistics and an exact ST_Intersects predicate against the admin polygon for Marion County.

Spatial join. This step matches candidate buildings. For every pair of SAM3 roof and Overture building whose geometries intersect, the query computes the intersection area, the two source areas, and the IoU:

SELECT
  s.sam3_id,
  s.bbox_score,
  o.overture_id,
  ST_Area(ST_Intersection(s.geom_m, o.geom_m)) AS inter_m2,
  ST_Area(s.geom_m) AS sam3_m2,
  ST_Area(o.geom_m) AS overture_m2,
  ST_Area(ST_Intersection(s.geom_m, o.geom_m)) /
    NULLIF(ST_Area(s.geom_m) + ST_Area(o.geom_m)
           - ST_Area(ST_Intersection(s.geom_m, o.geom_m)), 0) AS iou
FROM sam3_roofs s
JOIN overture_bldgs o
  ON ST_Intersects(s.geom_4326, o.geom_4326)

Intersection-over-union (IoU) is the area shared by two polygons divided by their combined area. A value of 1.0 is a perfect match; 0.0 means no overlap.

The ST_Intersects predicate runs on the lon/lat geometries, where Sedona’s spatial join planner has the column statistics it needs to be efficient; the ST_Area and ST_Intersection calls run on the UTM-projected geometries, where the resulting numbers are in square meters and meaningful. The query returns 207,109 candidate matched pairs and is cached for the rest of the analysis.

Resolution and metrics. Sometimes a single SAM3 polygon covers several Overture buildings, for example when SAM3 segments a multi-roof complex as one shape. Other times, several SAM3 polygons land on the same Overture building. To keep the comparison clean, the notebook keeps only the best-matching pair on each side. The remaining cells then compute the numbers the rest of the post relies on:

  • Recall. What fraction of Overture buildings has a matching SAM3 detection of roughly the right shape.
  • Precision. What fraction of SAM3 detections lines up with a known Overture building.
  • Calibration. Whether SAM3’s confidence score is a reliable signal of how well-shaped each detection actually is.
  • Aggregate area. How the total roof area SAM3 found across the county compares to the total building footprint area Overture has.

A final cell renders five high-IoU and five low-IoU matched pairs on aerial imagery for visual spot-checking, so I could see with my own eyes where SAM3 and Overture agree and where they don’t.

Example results comparing Overture building footprints (yellow) and SAM3 roof detections (blue). Top: a high quality result. Center: SAM3 failed to segment the roof in this building. Bottom: SAM3 segments part of the roof, but not the whole footprint.

SAM3 Building Detection Accuracy: Recall, Precision, and IoU Results

I wasn’t sure how best to assess the results. So I asked Claude to help with the analysis and here is a summary:

  • The overall correlation is very high. SAM3 and Overture report total roof area within 7% of each other across this county. That level of agreement says they are measuring the same physical objects, not coincidentally landing on the same total.
  • SAM3’s confidence score correlates to the accuracy of the polygon. SAM3 detections with high confidence scores have polygons that line up well with the real buildings, while lower confidence detections have less accurate shapes. That means we can use the confidence score as a reliable filter.
  • Many of SAM3’s “false positives” are real buildings Overture missed. The assistant pointed out that many of the SAM3 detections without an Overture match are actual rural houses and outbuildings visible on aerial imagery. So the real precision is likely higher than 75%.
  • When SAM3 finds a building, it usually gets the shape right. Only about 12 percentage points separate “found something on this building” from “found roughly the right shape on this building.” That means SAM3 is not just placing a point on the map, it is accurately outlining the building roofs.

These results are impressive! With a single text prompt (”roofs”), I was able to produce detections that match a curated, multi-source reference dataset to within 7% on total roof area, found the right shape on three-quarters of known buildings, and carried a confidence score an application can actually trust.

Even though SAM3 was not fine-tuned for roof detection, the resulting output would be operationally useful for many use cases.

Using the Spatial AI Coding Assistant to Evaluate Computer Vision ML Models

A few hours after I started, I had an initial assessment on the quality of the SAM3 detections. And I had a notebook with reproducible results that anyone can rerun in minutes. The assistant does not replace the role of the data scientist, but accelerates initial spatial and statistical analysis. And it did it while surfacing important caveats from the start.

Try It Yourself

Try the Spatial AI Coding Assistant

Key takeaways

  • A prior RasterFlow + SAM3 job built a 133 GB NAIP mosaic of Marion County, Oregon, ran text prompts across eight classes, and produced about one million detection polygons, including roughly 312,000 building roofs. This post evaluates those roof detections against Overture Maps buildings in the Wherobots Hub.
  • The evaluation was designed and executed in four prompts with the Spatial AI Assistant inside Claude, using the Wherobots MCP server. The assistant returned a 24-cell notebook. Area math uses UTM zone 10N (EPSG:32610), not EPSG:4326. The AOI is the official Marion County admin boundary from wkls, not a bounding box.
  • Inside the official county polygon: 261,414 SAM3 roofs vs 146,642 Overture buildings. The spatial join produced 207,109 candidate matched pairs. SAM3 and Overture total roof area agree within 7% across the county.
  • Caveats baked into the analysis: roofs are not footprints; Overture is not ground truth in rural areas and likely misses buildings; the 0.5 confidence cutoff was already applied in SAM3 outputs, limiting precision-recall to the upper half of the confidence range. Many SAM3 'false positives' are real rural buildings Overture missed, so real precision is likely higher than 75%.

Change Detection Using AlphaEarth Foundations (Part 2)

Author: Len Strnad

AlphaEarth Foundations embeddings gave us an initial view of temporal change in our earlier post on agricultural dynamics, but that first pass also exposed a limitation: raw embedding distances do not live on a consistent scale across pixels or land-cover types. As a result, it is hard to compare how unusual a given year looks from place to place, even when the relative signal is informative.

In this follow-up, we ask a more targeted question: can we score each annual embedding by how much it stands out from the rest of its local time series? To do that, we compute leave-one-out (LOO) medoid scores in embedding space and apply robust z-score scaling through time for each pixel. This gives us a more stable and interpretable way to identify unusual years across a short annual record.

As in the earlier AlphaEarth Foundations agriculture post, the goal is not to present a definitive change-detection benchmark. Instead, we show a practical workflow for surfacing unusual temporal states in AEF embeddings. We run that workflow against the global AEF Zarr mosaic hosted on Source Cooperative, build a multiscale output for exploration, and inspect several examples around Boulder, Colorado.

Global Zarr AEF Mosaic

For this follow-up we use the global AlphaEarth Foundations Zarr mosaic hosted on Source Cooperative. Thanks to Taylor Geospatial and Source Cooperative for making the dataset available in a form that is easy to access lazily in a single python function call!

The mosaic stores annual AEF embeddings at 10-meter resolution in EPSG:4326, with nine temporal snapshots from 2017 through 2025. Working from one global store keeps the workflow simple: we can subset the region of interest, compute scores, and publish a derived multiscale product without assembling separate local inputs.

import xarray as xr
ds = xr.open_zarr(
    "s3://us-west-2.opendata.source.coop/tge-labs/aef-mosaic/",
    consolidated=False,
    storage_options={"anon": True},
)
ds
/Users/len/code/wherobots-notebook-blogs/.pixi/envs/default/lib/python3.14/site-packages/zarr/core/group.py:3559: ZarrUserWarning: Object at README.md is not recognized as a component of a Zarr hierarchy.
  warnings.warn(
/Users/len/code/wherobots-notebook-blogs/.pixi/envs/default/lib/python3.14/site-packages/zarr/core/group.py:3559: ZarrUserWarning: Object at .checkpoint is not recognized as a component of a Zarr hierarchy.
  warnings.warn(
<xarray.Dataset> Size: 4PB
Dimensions:     (time: 9, band: 64, y: 1859584, x: 4009984)
Coordinates:
  * time        (time) int32 36B 2017 2018 2019 2020 2021 2022 2023 2024 2025
  * band        (band) object 512B 'A00' 'A01' 'A02' 'A03' ... 'A61' 'A62' 'A63'
  * y           (y) float64 15MB 83.69 83.69 83.69 ... -83.36 -83.36 -83.36
  * x           (x) float64 32MB -180.0 -180.0 -180.0 ... 180.2 180.2 180.2
Data variables:
    embeddings  (time, band, y, x) int8 4PB dask.array<chunksize=(1, 64, 256, 256), meta=np.ndarray>
Attributes: (12/15)
    proj:code:               EPSG:4326
    spatial:dimensions:      ['y', 'x']
    spatial:transform:       [8.983111749910169e-05, 0.0, -180.0, 0.0, -8.983...
    spatial:transform_type:  affine
    spatial:bbox:            [-180.0, -83.36280346631479, 180.2213438735178, ...
    spatial:shape:           [1859584, 4009984]
    ...                      ...
    geoemb:model:            https://developers.google.com/earth-engine/datas...
    geoemb:source_data:      https://source.coop/tge-labs/aef/v1/annual/
    geoemb:data_type:        int8
    geoemb:gsd:              8.983111749910169e-05
    geoemb:quantization:     {'method': 'signed_square', 'original_dtype': 'f...
    zarr_conventions:        [{'uuid': 'f17cb550-5864-4468-aeb7-f3180cfb622f'...

At first glance, 3.81 PiB and 1,024,049,664 chunks in S3 sounds impractically large. For this demo, though, a few details keep the workflow manageable:

  • only chunks that intersect land are present
  • the dataset is sharded into nine spatial chunks, which reduces the effective object count
  • we subset Colorado rather than operating on the full global mosaic

RasterFlow Tools Used

RasterFlow is Wherobots’ serverless inference engine for Earth Observation data. It builds inference-ready mosaics from remote sensing data and runs distributed workflows at scale. This notebook uses two RasterFlow capabilities:

Change Detection with RasterFlow Remote

For this run, we call run_mosaics_change on the RasterFlow Remote client. The method takes an input Zarr mosaic and writes out a derived Zarr mosaic containing one or more temporal change scores.

Here we compute both difference-over-time and LOO medoid scores in AEF embedding space, with robust z-score normalization applied across time for each pixel. The snippet below matches the current remote-client API used for this Colorado example.

from affine import Affine
from rasterio.transform import rowcol

# construct a bounding box selector for Colorado
transform = Affine(*ds.attrs["spatial:transform"])
row_min, col_min = rowcol(transform, -109.060253, 41.003444)
row_max, col_max = rowcol(transform, -102.041524, 37.000232)
selector = {"x": [col_min, col_max], "y": [row_min, row_max]}
selector
{'x': [np.int32(789701), np.int32(867833)],
 'y': [np.int32(475138), np.int32(519702)]}
from rasterflow_remote import DistanceMetricEnum, RasterflowClient, TemporalScoreMethodEnum

client = RasterflowClient()

result = client.run_mosaics_change(
    store="s3://us-west-2.opendata.source.coop/tge-labs/aef-mosaic/",
    score_methods=[
        TemporalScoreMethodEnum.DIFFERENCE,
        TemporalScoreMethodEnum.LOO_MEDOID,
    ],
    distance_metric=DistanceMetricEnum.ALPHA_EARTH_COSINE,
    data_var="embeddings",
    robust_time_zscore=True,
    selector=selector,
)

LOO Medoid Scores for Small Samples

To measure how unusual a given annual embedding is relative to its temporal context, we use a leave-one-out medoid (LOO medoid) score in embedding space. This is well suited to the AEF setting here, where we only have nine annual observations and want a reference that is less sensitive to outliers than a simple average.

Given a time series of embeddings {e1,…,eT}\{e_1, \dots, e_T\}, the LOO medoid score at time tkt_k compares eke_k to a representative embedding computed from the remaining observations:

E−k={ej∣j≠k}. E_{-k} = \{e_j \mid j \neq k\}.

We define that representative as the medoid of E−kE_{-k}, i.e. the embedding that minimizes total distance to all others:

emedoid(−k)=arg⁡minei∈E−k∑ej∈E−kd(ei,ej). e_{\text{medoid}}^{(-k)} = \arg\min_{e_i \in E_{-k}} \sum_{e_j \in E_{-k}} d(e_i, e_j).

The LOO medoid score is then: LOOmedoid(tk)=d(ek,emedoid(−k)), \mathrm{LOO}_{\mathrm{medoid}}(t_k) = d\left(e_k, e_{\text{medoid}}^{(-k)}\right),

where d(⋅,⋅)d(\cdot, \cdot) is a distance metric (e.g., Euclidean or cosine distance).

Interpretation: LOOmedoid(tk)\mathrm{LOO}_{\mathrm{medoid}}(t_k) measures how far the embedding at time tkt_k is from the medoid of the remaining time series. Larger values indicate that the year-specific embedding is less consistent with the rest of the temporal sequence, suggesting a potential change or anomalous state with respect to all other years.

Results

Using the workflow above, we compute two normalized change layers across the nine-year Colorado subset of the AEF mosaic: a difference-over-time score and a LOO medoid score.

The intermediate result below is a Zarr dataset. You may notice:

  • 9 time steps, corresponding to the annual AEF embeddings from 2017 through 2025
  • 2 score bands, one for consecutive difference and one for LOO medoid deviation
  • ~235Gb of data
ds = xr.open_zarr(
    "s3://sandbox-wherobots-mosaics-tmp/rasterflow/rasterflow/development/rbz2h49wvpjt7h4nzq5g/76e986b947cc.zarr"
)
ds
<xarray.Dataset> Size: 253GB
Dimensions:     (time: 9, band: 2, y: 44800, x: 78336)
Coordinates:
  * time        (time) int32 36B 2017 2018 2019 2020 2021 2022 2023 2024 2025
  * band        (band) object 16B 'difference_alpha_earth_cosine_distance_rob...
  * y           (y) float64 358kB 41.0 41.0 41.0 41.0 ... 36.98 36.98 36.98
  * x           (x) float64 627kB -109.1 -109.1 -109.1 ... -102.0 -102.0 -102.0
Data variables:
    embeddings  (time, band, y, x) float32 253GB dask.array<chunksize=(1, 1, 256, 256), meta=np.ndarray>
Attributes: (12/15)
    proj:code:               EPSG:4326
    spatial:dimensions:      ['y', 'x']
    spatial:transform:       [8.983111749910169e-05, 0.0, -180.0, 0.0, -8.983...
    spatial:transform_type:  affine
    spatial:bbox:            [-180.0, -83.36280346631479, 180.2213438735178, ...
    spatial:shape:           [1859584, 4009984]
    ...                      ...
    geoemb:model:            https://developers.google.com/earth-engine/datas...
    geoemb:source_data:      https://source.coop/tge-labs/aef/v1/annual/
    geoemb:data_type:        int8
    geoemb:gsd:              8.983111749910169e-05
    geoemb:quantization:     {'method': 'signed_square', 'original_dtype': 'f...
    zarr_conventions:        [{'uuid': 'f17cb550-5864-4468-aeb7-f3180cfb622f'...

Visualization

To make the result easier to explore, we run the build_zarr_multiscales function to create a multiscale Zarr mosaic optimized for interactive viewing and analysis, then publish it to a public bucket for anyone to explore.

xr.open_datatree("s3://wherobots-examples/rasterflow/mosaics/co-aef-change.zarr")
<xarray.DataTree>
Group: /
│   Attributes:
│       zarr_conventions:        [{'uuid': 'd35379db-88df-4056-af3a-620245f8e347'...
│       multiscales:             {'layout': [{'asset': '0', 'transform': {'scale'...
│       proj:code:               EPSG:4326
│       proj:wkt2:               GEOGCRS["WGS 84",ENSEMBLE["World Geodetic System...
│       spatial:dimensions:      ['y', 'x']
│       spatial:registration:    pixel
│       spatial:transform_type:  affine
│       spatial:shape:           [44800, 78336]
│       spatial:transform:       [8.983111749216732e-05, 0.0, -109.07797340998921...
│       spatial:bbox:            [-109.07797340998921, 36.97927342911413, -102.04...
├── Group: /0
│       Dimensions:      (time: 9, band: 2, y: 44800, x: 78336)
│       Coordinates:
│         * time         (time) int32 36B 2017 2018 2019 2020 2021 2022 2023 2024 2025
│         * band         (band) StringDType() 32B 'difference_alpha_earth_cosine_dist...
│         * y            (y) float64 358kB 41.0 41.0 41.0 41.0 ... 36.98 36.98 36.98
│         * x            (x) float64 627kB -109.1 -109.1 -109.1 ... -102.0 -102.0 -102.0
│       Data variables:
│           embeddings   (time, band, y, x) float32 253GB ...
│           spatial_ref  int32 4B ...
├── Group: /1
│       Dimensions:      (time: 9, band: 2, y: 22400, x: 39168)
│       Coordinates:
│         * time         (time) int32 36B 2017 2018 2019 2020 2021 2022 2023 2024 2025
│         * band         (band) StringDType() 32B 'difference_alpha_earth_cosine_dist...
│         * y            (y) float64 179kB 41.0 41.0 41.0 41.0 ... 36.98 36.98 36.98
│         * x            (x) float64 313kB -109.1 -109.1 -109.1 ... -102.0 -102.0 -102.0
│       Data variables:
│           embeddings   (time, band, y, x) float32 63GB ...
│           spatial_ref  int32 4B ...
├── Group: /2
│       Dimensions:      (time: 9, band: 2, y: 11200, x: 19584)
│       Coordinates:
│         * time         (time) int32 36B 2017 2018 2019 2020 2021 2022 2023 2024 2025
│         * band         (band) StringDType() 32B 'difference_alpha_earth_cosine_dist...
│         * y            (y) float64 90kB 41.0 41.0 41.0 41.0 ... 36.98 36.98 36.98
│         * x            (x) float64 157kB -109.1 -109.1 -109.1 ... -102.0 -102.0 -102.0
│       Data variables:
│           embeddings   (time, band, y, x) float32 16GB ...
│           spatial_ref  int32 4B ...
├── Group: /3
│       Dimensions:      (time: 9, band: 2, y: 5600, x: 9792)
│       Coordinates:
│         * time         (time) int32 36B 2017 2018 2019 2020 2021 2022 2023 2024 2025
│         * band         (band) StringDType() 32B 'difference_alpha_earth_cosine_dist...
│         * y            (y) float64 45kB 41.0 41.0 41.0 41.0 ... 36.98 36.98 36.98
│         * x            (x) float64 78kB -109.1 -109.1 -109.1 ... -102.0 -102.0 -102.0
│       Data variables:
│           embeddings   (time, band, y, x) float32 4GB ...
│           spatial_ref  int32 4B ...
├── Group: /4
│       Dimensions:      (time: 9, band: 2, y: 2800, x: 4896)
│       Coordinates:
│         * time         (time) int32 36B 2017 2018 2019 2020 2021 2022 2023 2024 2025
│         * band         (band) StringDType() 32B 'difference_alpha_earth_cosine_dist...
│         * y            (y) float64 22kB 41.0 41.0 41.0 41.0 ... 36.98 36.98 36.98
│         * x            (x) float64 39kB -109.1 -109.1 -109.1 ... -102.0 -102.0 -102.0
│       Data variables:
│           embeddings   (time, band, y, x) float32 987MB ...
│           spatial_ref  int32 4B ...
├── Group: /5
│       Dimensions:      (time: 9, band: 2, y: 1400, x: 2448)
│       Coordinates:
│         * time         (time) int32 36B 2017 2018 2019 2020 2021 2022 2023 2024 2025
│         * band         (band) StringDType() 32B 'difference_alpha_earth_cosine_dist...
│         * y            (y) float64 11kB 41.0 41.0 41.0 40.99 ... 36.99 36.98 36.98
│         * x            (x) float64 20kB -109.1 -109.1 -109.1 ... -102.0 -102.0 -102.0
│       Data variables:
│           embeddings   (time, band, y, x) float32 247MB ...
│           spatial_ref  int32 4B ...
├── Group: /6
│       Dimensions:      (time: 9, band: 2, y: 700, x: 1224)
│       Coordinates:
│         * time         (time) int32 36B 2017 2018 2019 2020 2021 2022 2023 2024 2025
│         * band         (band) StringDType() 32B 'difference_alpha_earth_cosine_dist...
│         * y            (y) float64 6kB 41.0 41.0 40.99 40.98 ... 36.99 36.99 36.98
│         * x            (x) float64 10kB -109.1 -109.1 -109.1 ... -102.1 -102.0 -102.0
│       Data variables:
│           embeddings   (time, band, y, x) float32 62MB ...
│           spatial_ref  int32 4B ...
├── Group: /7
│       Dimensions:      (time: 9, band: 2, y: 350, x: 612)
│       Coordinates:
│         * time         (time) int32 36B 2017 2018 2019 2020 2021 2022 2023 2024 2025
│         * band         (band) StringDType() 32B 'difference_alpha_earth_cosine_dist...
│         * y            (y) float64 3kB 41.0 40.99 40.97 40.96 ... 37.01 37.0 36.99
│         * x            (x) float64 5kB -109.1 -109.1 -109.0 ... -102.1 -102.1 -102.0
│       Data variables:
│           embeddings   (time, band, y, x) float32 15MB ...
│           spatial_ref  int32 4B ...
├── Group: /8
│       Dimensions:      (time: 9, band: 2, y: 175, x: 306)
│       Coordinates:
│         * time         (time) int32 36B 2017 2018 2019 2020 2021 2022 2023 2024 2025
│         * band         (band) StringDType() 32B 'difference_alpha_earth_cosine_dist...
│         * y            (y) float64 1kB 40.99 40.97 40.95 40.92 ... 37.04 37.01 36.99
│         * x            (x) float64 2kB -109.1 -109.0 -109.0 ... -102.1 -102.1 -102.1
│       Data variables:
│           embeddings   (time, band, y, x) float32 4MB ...
│           spatial_ref  int32 4B ...
└── Group: /9
        Dimensions:      (time: 9, band: 2, y: 88, x: 153)
        Coordinates:
          * time         (time) int32 36B 2017 2018 2019 2020 2021 2022 2023 2024 2025
          * band         (band) StringDType() 32B 'difference_alpha_earth_cosine_dist...
          * y            (y) float64 704B 40.98 40.93 40.89 40.84 ... 37.07 37.03 36.98
          * x            (x) float64 1kB -109.1 -109.0 -109.0 ... -102.2 -102.1 -102.1
        Data variables:
            embeddings   (time, band, y, x) float32 969kB ...
            spatial_ref  int32 4B ...

Using the multiscales convention and building on carbonplan’s zarr-layer, we can visualize the nine-year mosaic.

If the viewer below is not working, try fullscreen mode here.

Examples of Changes in Boulder, Colorado

The video below shows the normalized change scores around Boulder, Colorado. It is a quick qualitative pass rather than a formal validation exercise, but several patterns stand out more clearly once the scores are viewed over time.

Takeaways and Next Steps

This follow-up suggests that leave-one-out medoid scoring, combined with robust time-wise normalization, is more useful for inspecting AEF change signal than raw distance alone. The normalization makes change magnitude more comparable across pixels, while the leave-one-out medoid highlights anomalous signal across the full observation window rather than only between adjacent time steps. This does not make AEF embeddings a complete change-detection product, but it does provide a clearer and more consistent way to identify which years look unusual at each pixel.

That matters because AEF operates at a spatial and temporal resolution where many changes are subtle, cumulative, or mixed with recurring seasonal variation. A normalized score does not solve interpretation by itself, but it makes the first-pass search for interesting change much more practical.

In this notebook, we used run_mosaics_change on the RasterFlow Remote client to compute change scores from the global AEF mosaic, built a multiscales derivative for browser-based exploration, and reviewed several examples around Boulder. Followup work may include using the embeddings to also help filter the object types of interest for taking yet another step closer towards a more complete change-detection product, especially where we have external labels or stronger domain priors.

If you are interested in this approach, reach out to the Wherobots team at support@wherobots.com.

For a complementary approach aggregating embeddings at the field level, see Zonal Statistics on AlphaEarth Embeddings.

Get Started with RasterFlow

Key takeaways

  • Raw AlphaEarth Foundations embedding distances do not live on a consistent scale across pixels or land-cover types. This follow-up scores each annual embedding by how much it stands out from the rest of its local time series using leave-one-out (LOO) medoid scores in embedding space, then robust z-score scaling through time per pixel.
  • The workflow reads the global AEF Zarr mosaic on Source Cooperative: annual 64-band embeddings at 10-meter resolution in EPSG:4326, nine snapshots from 2017 through 2025. The global store is 3.81 PiB with about 1.02 billion chunks; the demo subsets Colorado rather than the full mosaic, and only land-intersecting chunks are present.
  • RasterFlow's run_mosaics_change computed both difference-over-time and LOO medoid scores with AlphaEarth cosine distance and robust_time_zscore=True. The Colorado result is a ~235 GB Zarr (9 time steps × 2 score bands). build_zarr_multiscales produced a public multiscale mosaic at s3://wherobots-examples/rasterflow/mosaics/co-aef-change.zarr.
  • The post is a practical workflow, not a definitive change-detection benchmark. LOO medoid plus robust time-wise normalization makes change magnitude more comparable across pixels and highlights anomalous years across the full nine-year window, not only between adjacent years. Examples around Boulder are qualitative.

AlphaEarth Embeddings, Zonal Statistics, and PCA

Author: Len Strnad

AlphaEarth Foundations embeddings can be useful at the pixel level when the task is tightly local, such as inspecting boundaries, fine-scale heterogeneity, or small spatial disturbances. But many practical workflows need signals that stay meaningful after aggregation, whether to fields, parcels, patches, or other management units. Zonal statistics provide a simple way to test that idea: aggregate embeddings within field polygons and ask whether the field-level vectors still preserve interpretable structure.

In this notebook, we compute mean AlphaEarth Foundations (AEF) embeddings for about 3.5 million Iowa fields across 2023-2025, then project those field-level vectors into RGB using the first three PCA components. The result is a GeoParquet dataset we can inspect spatially and compare across fields.

The main takeaway is straightforward: the aggregated embeddings form visible clusters that may correspond to crop type or management practice. That does not prove a downstream classifier will work, but it is encouraging evidence that zonal AEF summaries could support lightweight field-level analysis with simple models rather than per-pixel inference.

RasterFlow Tools Used

RasterFlow is Wherobots’ serverless inference engine for Earth Observation data. It builds inference-ready mosaics from remote sensing data and runs distributed workflows at scale. This notebook uses four capabilities:

  • build_and_predict_mosaic_recipe on the RasterFlow Remote client for building a mosaic and running inference using the Fields of the World model.
  • vectorize_mosaic on the RasterFlow Remote client for converting field predictions from raster mosaics into GeoParquet.
  • An experimental zonal-statistics workflow for aggregating AEF embeddings over field geometries.
  • An experimental PCA workflow for appending the first three components for visualization.

Global Zarr AEF Mosaic

For this analysis, we use the global AlphaEarth Foundations Zarr mosaic hosted on Source Cooperative. Thanks to Taylor Geospatial and Source Cooperative for publishing the dataset in a form that can be accessed lazily with standard Python tooling.

The mosaic stores annual AEF embeddings at 10-meter resolution in EPSG:4326, with nine temporal snapshots from 2017 through 2025. Working from one global store keeps the workflow simple: we can subset Iowa, compute field-level summaries, and publish a derived product without assembling local inputs.

import xarray as xr
ds = xr.open_zarr("s3://us-west-2.opendata.source.coop/tge-labs/aef-mosaic/", consolidated=False)
ds
<xarray.Dataset> Size: 4PB
Dimensions:     (time: 9, band: 64, y: 1859584, x: 4009984)
Coordinates:
  * time        (time) int32 36B 2017 2018 2019 2020 2021 2022 2023 2024 2025
  * band        (band) object 512B 'A00' 'A01' 'A02' 'A03' ... 'A61' 'A62' 'A63'
  * y           (y) float64 15MB 83.69 83.69 83.69 ... -83.36 -83.36 -83.36
  * x           (x) float64 32MB -180.0 -180.0 -180.0 ... 180.2 180.2 180.2
Data variables:
    embeddings  (time, band, y, x) int8 4PB dask.array<chunksize=(1, 64, 256, 256), meta=np.ndarray>
Attributes: (12/15)
    proj:code:               EPSG:4326
    spatial:dimensions:      ['y', 'x']
    spatial:transform:       [8.983111749910169e-05, 0.0, -180.0, 0.0, -8.983...
    spatial:transform_type:  affine
    spatial:bbox:            [-180.0, -83.36280346631479, 180.2213438735178, ...
    spatial:shape:           [1859584, 4009984]
    ...                      ...
    geoemb:model:            https://developers.google.com/earth-engine/datas...
    geoemb:source_data:      https://source.coop/tge-labs/aef/v1/annual/
    geoemb:data_type:        int8
    geoemb:gsd:              8.983111749910169e-05
    geoemb:quantization:     {'method': 'signed_square', 'original_dtype': 'f...
    zarr_conventions:        [{'uuid': 'f17cb550-5864-4468-aeb7-f3180cfb622f'...

At first glance, 3.81 PiB and 1,024,049,664 chunks in S3 sound impractically large. For this demo, a few details keep the workflow manageable:

  • only land-intersecting chunks are present
  • the dataset is sharded into nine spatial chunks, which lowers the effective object count
  • we subset Iowa rather than operating on the full global mosaic

This mosaic is the same one used in Change Detection Using AlphaEarth Foundations (Part 2).

Zonal Statistics Experimental Workflow

We use rayzon, a side project I built to compute zonal statistics using Ray.

Without tuning, it computes zonal statistics for about 3 million Iowa fields across three years in roughly five minutes on a Ray cluster with eight workers and 16 cores per worker.

It also supports t-digest-backed quantile estimation for statistics beyond mean, min, and max, though that path is still experimental. If this workflow is relevant to your team, let us know so we can prioritize productizing this type of analysis in RasterFlow.

Zonal Statistics Results

Using that workflow, we compute mean zonal statistics for Iowa fields across three years of the global AEF Zarr mosaic.

The resulting GeoParquet is published at s3://wherobots-examples/rasterflow/vectors/iowa_aef_zonal_stats/ for anyone who wants to inspect it.

import pyarrow.parquet as pq

ds = pq.ParquetDataset("s3://wherobots-examples/rasterflow/vectors/iowa_aef_zonal_stats/")
ds.schema
geometry: binary
score_mean: float
score_std: float
source_store: string
local_x_offset: int64
local_y_offset: int64
global_x_offset: int64
global_y_offset: int64
path: string
feature_id: int64
bbox: struct<xmax: double, xmin: double, ymax: double, ymin: double>
  child 0, xmax: double
  child 1, xmin: double
  child 2, ymax: double
  child 3, ymin: double
mean: list<element: double>
  child 0, element: double
pca_components: list<element: float>
  child 0, element: float
hilbert_key: int64
time: dictionary<values=string, indices=int32, ordered=0>
label: dictionary<values=string, indices=int32, ordered=0>
-- schema metadata --
geo: '{"version": "1.1.0", "primary_column": "geometry", "columns": {"geo' + 1808

The schema exposes three columns of interest:

  • geometry: the geometry of each field, with one row per field
  • mean: a 64-element list containing the mean value for each AEF embedding dimension within the field
  • pca_components: a 3-element list containing the first three PCA components of each mean embedding, used here as RGB channels for visualization

We also partition the dataset using a Hive-style layout on time and label. In this example, label is always field; the model also produces field_boundaries and non_field, but those classes are not the focus here.

# what are the partitioning keys?
ds.partitioning.dictionaries
[<pyarrow.lib.StringArray object at 0x14199d660>
 [
   "2023-01-01",
   "2024-01-01",
   "2025-01-01"
 ],
 <pyarrow.lib.StringArray object at 0x14199d1e0>
 [
   "field"
 ]]

The output contains about 3.5 million Iowa fields across the 2023-2025 period.

sum([fragment.count_rows() for fragment in ds.fragments])
3516044

Visualizing Confidences

The dataset also includes score_mean, the mean model confidence over each polygonized field. Because fields are formed from contiguous pixels above the vectorization threshold, this gives us a compact way to inspect how confident the model was within each extracted geometry. Here we use the 0.5 threshold from vectorize_mosaic.

If the viewer below is not working, try fullscreen mode here.

We can see a great deal of variance in the confidence scores between fields, which can provide some critical feedback for modeling efforts and help guide stratefied sampling for well-balaned training sets.

Visualizing Zonal Statistics of Mean Embeddings with PCA

We also visualize each field’s mean AEF embedding by mapping the first three PCA components to RGB in GeoParquet.

If the viewer below is not working, try fullscreen mode here.

The map shows clear color clustering, especially purples, yellow-greens, and blues. To check whether that spatial pattern is reflected numerically, we next plot the first two PCA components in a scatter plot.

import pandas as pd
import matplotlib.pyplot as plt

df = pd.read_parquet(
    "s3://wherobots-examples/rasterflow/vectors/iowa_aef_zonal_stats/",
    columns=["pca_components"],
)

sample = df.sample(n=20000)
sample["pca_1"] = sample["pca_components"].apply(lambda x: x[0])
sample["pca_2"] = sample["pca_components"].apply(lambda x: x[1])
sample["pca_3"] = sample["pca_components"].apply(lambda x: x[2])

sample.plot.scatter(
    x="pca_1", y="pca_2", c=sample["pca_components"].tolist(), alpha=0.5, figsize=(10, 8)
)

# overlay 2d density plot
plt.hexbin(sample["pca_1"], sample["pca_2"], gridsize=50, cmap='Greys', alpha=0.2, marginals=True)

# add normalized density to 
plt.title("PCA Components Colored by (3) PCA Components")
plt.xlabel("PCA Component 1")
plt.ylabel("PCA Component 2")
plt.show()

We see three broad clusters in the 2D PCA scatter plot, and the cluster colors align with the RGB visualization above. The purple and green groups appear tighter than the yellow group, but the overall separation is still encouraging. That suggests a simple linear probe on top of the AEF embeddings may be able to distinguish field types or management patterns without manual feature engineering or complex models.

Beyond classification, AEF may also be useful for stratified sampling, supervised training set design, or in situ analysis of field management practices.

Takeaways and Next Steps

  • Aggregated AEF embeddings remain informative at field scale, not just pixel scale.
  • The zonal-statistics workflow scales to millions of fields and hundreds of millions of pixels on a modest Ray cluster.
  • GeoParquet and a single global Zarr mosaic work well together for this style of analysis.
  • Field-level confidence scores provide an additional quality signal alongside the embeddings.
  • The PCA visualization shows clear clustering that may relate to crop type or management practice and is worth testing with labeled data.
  • For change detection applications using the same embeddings, see Change Detection Using AlphaEarth Foundations (Part 2) and How Agricultural Fields Change in AlphaEarth Foundations.

If you are interested in this approach, reach out to the Wherobots team at support@wherobots.com.

Sign up for RasterFlow Preview

Key takeaways

  • Mean AlphaEarth Foundations embeddings were computed for about 3.5 million Iowa fields (3,516,044 rows) across 2023–2025, then projected to RGB using the first three PCA components. The result is a GeoParquet dataset at s3://wherobots-examples/rasterflow/vectors/iowa_aef_zonal_stats/.
  • The experimental zonal-statistics workflow (rayzon) computed zonal stats for about 3 million Iowa fields across three years in roughly five minutes on a Ray cluster with eight workers and 16 cores per worker, without tuning.
  • Each row has a field geometry, a 64-element mean embedding, and a 3-element pca_components list used as RGB. The dataset is Hive-partitioned on time (2023-01-01, 2024-01-01, 2025-01-01) and label (field). Field polygons come from Fields of the World predictions vectorized at a 0.5 threshold.
  • PCA scatter of 20,000 sampled fields shows three broad clusters (purples, yellow-greens, blues) that may correspond to crop type or management practice. That is encouraging evidence for lightweight field-level analysis with simple models, not proof a downstream classifier will work.

Detecting Objects From Text Prompts with RasterFlow and Segment Anything 3

RasterFlow now Supports Detections from Text!

RasterFlow now makes it simple to run promptable geospatial vision models across large aerial and satellite imagery collections, removing the need to build bespoke inference pipelines. With our new SAM 3 support, you can prompt for concepts like “roofs”, “roads”, or “shipping containers” and turn those detections into vector outputs ready for analysis in Wherobots, from city scale to country scale.

RasterFlow visualization tool showing SAM 3 detections.

Here we see SAM 3 localize and capture roofs really well. It also segments roads pretty cleanly, though there is still room for improvement. Results are shown on the inference input, a NAIP 30 centimeter basemap, and include all instance pixels above 50% confidence.

In this post, we put SAM 3 to the test detecting roofs in suburban neighborhoods, shipping containers in crowded loading docks, and tractors across agricultural landscapes. Throughout, we comment on where it succeeds, where it falls short, and how results compare to previous SAM models. Finally, we discuss what’s next for computer vision in Earth observation (EO) and how the community can build on models like SAM to create more promptable, flexible applications that handle the scale and diversity of remote sensing imagery.

How Segment Anything Model 3 Improves on SAM 1 and SAM 2 for Remote Sensing

In 2023, the Segment Anything Model (SAM) set a new paradigm for computer vision. While most models were trained to address one task at a time, SAM demonstrated that a single model could reliably classify, localize, and segment many kinds of objects in complex natural scenes.

SAM panoptic segmentation examples.
SAM mask predictions across diverse scenes.

SAM set a new standard task for computer vision models that went beyond classification or semantic segmentation: Promptable Visual Segmentation (PVS), which takes spatial prompts such as boxes, points, or masks as input and predicts masks.

However, SAM 1’s performance on out-of-distribution imagery domains had issues. Back in 2023, while I was working on detection projects with 10 meter resolution imagery, I found it often took multiple rounds of prompting to get useful segments, and many results still needed manual cleanup.

For example, in a rural town, SAM 1 can segment many features across different spatial scales, but it still misses all roads.

SAM 1 segmenting features in rural Dikwa, Nigeria.
SAM 1 segments many features across different spatial scales in Dikwa, Nigeria, but still misses roads.

SAM 2 improved accuracy and added video support, but both SAM 1 and SAM 2 were limited to making predictions without associated labels. The masks they produced were not grounded in categories useful for deriving insights.

Fast forward to today: SAM 3 addresses Promptable Concept Segmentation (PCS), a task where a model can accept either spatial prompts (masks, boxes, pixels) or text prompts like “cat”, “dog”, even “roofs”!

The SAM 3 architecture.
The SAM 3 architecture supports Promptable Concept Segmentation, accepting text and image exemplars in addition to spatial prompts to produce masks with labeled concepts. It also benefits from pretraining on overhead imagery. Source: YouTube.

This opens up new possibilities for detecting objects in imagery. Because SAM 3’s training data spans many imaging domains, including overhead aerial imagery, it can succeed in many Earth observation contexts where previous model generations struggled. For example, one can use SAM 3 to predict “roofs” in NAIP imagery simply by asking, with no model training or ad hoc labeling needed, and get pretty stellar results.

Roofs detected across an urban scene.
With RasterFlow, we can create imagery mosaics and predict roofs simply by prompting SAM 3 with short noun phrases like “roofs”.

To put SAM 3 to the test on Earth observation imagery, we generated many more SAM 3 predictions on top of National Agriculture Program 30 centimeter imagery using RasterFlow, our scalable mosaic building and inference engine. Below details the performance of RasterFlow on this high resolution detection task.

RasterFlow TaskBilled RuntimeOutputTotal Output SizeSpatial Scale of Output
build_gti_mosaic11m 47sNAIP Zarr mosaic133.08 GB (123.94 GiB)~114.15 km × 66.81 km
predict_mosaic_geometries39m 51sGeoParquet geometry results263.85 MB (251.63 MiB)~111.02 km × 66.81 km

See an AI agent take plain-language question and orchestrating pipelines to end results using RasterFlow and the Wherobots MCP server, check out the upcoming live session.

mcp rasterflow energy webinar
See it in action

How to Run SAM 3 on Aerial and Satellite Imagery with RasterFlow

What used to take a complex mix of imagery ETL, bespoke inference pipelines, and self-provisioned infrastructure for large Earth observation processing now takes under an hour and two simple Python functions. Let’s check it out below.

Build an Aerial Imagery Mosaic from NAIP

First, we will generate a mosaic: a seamless, stitched-together image from many independent remote sensing scenes. RasterFlow handles all the data sourcing, loading, cleaning, and partitioning into a data asset optimized for inference for a particular model.

from rasterflow_remote import RasterflowClient

rf_client = RasterflowClient()
mosaic_output = rf_client.build_gti_mosaic(
    gti="s3://wherobots-examples/rasterflow/indexes/naip_index.parquet",
    aoi="s3://wherobots-examples/rasterflow/aois/marion_county.parquet",
    bands=["red", "green", "blue", "nir"],
    location_field="url",
    crs_epsg=3857,
    time_column="year",
    skip_xy_coords=False,
    xy_chunksize=1024,
    query="res == 0.3 and time >= '2022-01-01' and time <= '2023-01-01'",
    requester_pays=True,
    sort_field="time",
)
print(mosaic_output.uri)

Run the SAM 3 Inference on the Mosaic

With our mosaic built, we can now run inference with SAM 3. Unlike more rigid models which only predict one category, SAM 3 can accept one or more text prompts and detect all matching objects in a single pass. The runtime for a given batch of imagery scales linearly with the number of detections. More prompts tends to mean more detections, so keep in mind that the more prompts you add, the longer you can expect an inference run to take.

from rasterflow_remote.data_models import GeometryActorEnum, MergeModeEnum

model_output = rf_client.predict_mosaic_geometries(
    store="s3://wherobots-examples/rasterflow/mosaics/marion_county.zarr",
    model_path="https://huggingface.co/wherobots/sam3-text-geometry-pt2/resolve/main/full_sam3_pipeline.pt2",
    patch_size=1008,
    clip_size=0,
    device="cuda",
    features=["red", "green", "blue"],
    labels=["roads", "airplanes", "airports", "roofs", "solar panels", "swimming pools", "shipping containers", "tractors"],
    actor=GeometryActorEnum.TEXT_TO_VECTOR_GEOMETRIES,
    max_batch_size=1,
    confidence_threshold=0.5,
    merge_mode=MergeModeEnum.NONE,
    xy_block_multiplier=1,
)

Analyze the SAM 3 Detections with WherobotsDB

We can visualize both the mosaic and detections directly in the notebook with an embedded RasterFlow map. The RGB imagery mosaic and the SAM 3 detections have been web-optimized with RasterFlow for fast and fluid browsing.

If the embed does not load in your environment, open it in a new tab here.

After visually exploring detections, we can load the results into WherobotsDB for quantitative analysis. WherobotsDB lets us post-process geometries, calculate zonal statistics on other rasters, count objects, measure clustering, and much more.

import os
from sedona.spark import *
from pyspark.sql.functions import *
from wherobots import vtiles

config = (
    SedonaContext.builder()
    .getOrCreate()
)
sedona = SedonaContext.create(config)

parquet_path = "s3://wherobots-examples/rasterflow/model-outputs/marion_county_sam3/"
df = sedona.read.format("geoparquet").load(parquet_path)

df.printSchema()
df.show(10)

Here we’ll use ST_Area to estimate the total square km area of all roofs in Marion County.

roofs_df = df.filter(col("label") == "roofs")
source_srid = roofs_df.selectExpr("ST_SRID(geometry) AS srid").first()["srid"]
source_crs = f"EPSG:{source_srid}"
print(source_crs)
# Run the SRID inspection cell above first so source_crs reflects the data's actual CRS.
roof_areas = roofs_df.withColumn(
    "area_sq_m",
    expr(f"ST_AreaSpheroid(ST_Transform(geometry, '{source_crs}', 'EPSG:4326'))"),
).cache()

roof_areas_agg = roof_areas.agg(
    sum("area_sq_m").alias("total_area_sq_m"),
    (sum("area_sq_m") / lit(1_000_000.0)).alias("total_area_sq_km"),
)

roof_areas_agg.show(truncate=False)
roof_size_stats = roof_areas.agg(
    count("*").alias("roof_count"),
    avg("area_sq_m").alias("avg_roof_sq_m"),
    expr("percentile_approx(area_sq_m, 0.5)").alias("median_roof_sq_m"),
    (avg("area_sq_m") * lit(10.7639)).alias("avg_roof_sq_ft"),
)

stats = roof_size_stats.first()

print(f"roof_count: {stats['roof_count']:,}")
print(f"avg_roof_sq_m: {stats['avg_roof_sq_m']:.2f}")
print(f"median_roof_sq_m: {stats['median_roof_sq_m']:.2f}")
label_counts = df.groupBy("label").count().orderBy(col("count").desc(), col("label"))
label_counts.show(truncate=False)

We can also generate web-optimized vectors in PMTiles format. PMTiles are easily shareable and plug directly into geospatial visualization applications, making them a great choice for distributing detection results.

df_tiles = df.withColumn("layer", col("label"))

output_path = 's3://wherobots-examples/rasterflow/model-outputs/marion_county_sam3.pmtiles'
vtiles.generate_pmtiles(df_tiles, output_path)

SAM 3 Accuracy on Earth Observation: Where It Succeeds and Where It Falls Short

After experimenting with SAM 3 on NAIP, I’m impressed with the range of categories that SAM 3 can positively identify. At the same time, the model still has clear precision and recall gaps for more niche semantic categories, like “tractors” or “shipping containers”.

SAM 3 detecting tractors in a field.
SAM 3 correctly detects tractors in a harvested field.
SAM 3 detecting tractors at a dealer lot.
In an agricultural context, SAM 3 finds some tractors but also misses some.
SAM 3 detecting shipping containers in a storage yard.
SAM 3 correctly identifies shipping containers in a storage yard.
SAM 3 false positive shipping container detections on rectangular structures.
False positives: SAM 3 labels rectangular roofs as “shipping containers”.

These examples illustrate the gap between SAM 3’s strengths on common categories and its current limitations on more niche ones. I’m bullish that if SAM 3 were fine-tuned on a larger corpus of mixed high-resolution imagery and high-quality labels, it would perform even better outside of its primary domain of natural imagery.

Even so, I think the potential for SAM 3 for simpler categories like “roofs” is underutilized. SAM 3 could potentially improve many existing datasets we rely on to make decisions, like Overture Buildings and detections of other kinds of structures.

What to Try Next with SAM 3 on Wherobots

Some things we didn’t showcase but I recommend trying in your experiments:

  • Can you find and count vehicles that are a certain color?
  • Can you use SAM 3 to segment city streets? What about rural roads?
  • Try to map forest canopy! SAM 3 can do a pretty great job at segmenting concepts that don’t map cleanly to individual objects, but are more textural.
  • For a larger-scale example of RasterFlow in production, see how RasterFlow processed 2.3B Overture Maps features for Fields of the World.

If you’re excited to try SAM 3 on Wherobots, sign up for RasterFlow Private Preview. You can also get in touch at ryan@wherobots.com or talk to us here. I’d love to hear about what you’re looking to build and how SAM 3 could fit into your detection workflows.

Try SAM3 on Wherobots

Key takeaways

  • RasterFlow now supports SAM 3 promptable concept segmentation: text prompts such as ‘roofs’, ‘roads’, or ‘shipping containers’ produce vector outputs from aerial and satellite mosaics. SAM 3 accepts spatial prompts or text, and it was pretrained on overhead imagery, unlike SAM 1/2.
  • On Marion County NAIP 30 cm, build_gti_mosaic billed 11 minutes 47 seconds and wrote a 133.08 GB Zarr mosaic (~114.15 km × 66.81 km). predict_mosaic_geometries billed 39 minutes 51 seconds and wrote 263.85 MB of GeoParquet. Eight labels were prompted in one pass at confidence_threshold 0.5.
  • SAM 3 localizes roofs well on the NAIP basemap and segments roads reasonably, with room for improvement. Niche classes such as tractors and shipping containers show clear precision and recall gaps, including rectangular roofs labeled as shipping containers.
  • What used to take a mix of imagery ETL, bespoke inference pipelines, and self-provisioned infrastructure now takes under an hour and two Python functions: build_gti_mosaic then predict_mosaic_geometries. Detections load into WherobotsDB as GeoParquet for ST_AreaSpheroid, counts, and PMTiles via vtiles.generate_pmtiles.

How We Delivered “Fields of The World” with RasterFlow: A Planetary-Scale GeoAI Pipeline

Author: Len Strnad

Fields of the World (FTW) is a Taylor Geospatial effort to produce globally consistent agricultural field-boundary data for land-use monitoring, food-system analysis, and model development. For the 2024-2025 global release, Taylor Geospatial partnered with Wherobots to run their latest FTW model, PRUE, on Wherobots RasterFlow; see the PRUE paper.

In this notebook, we walk through the production pipeline behind that release. The PRUE model turns seasonal Sentinel-2 composites into global field and field_boundaries predictions, and RasterFlow handles the larger systems problem: running data access, compositing, inference, and vectorization as one coordinated workflow and publishing the results as analysis-ready raster and vector products.

The focus here is practical architecture rather than model internals. We cover how the pipeline is structured, what it produced at global scale, and the implementation choices that made the run reproducible.

What we cover:

  1. RasterFlow and why it is designed for reproducible, scalable global runs.
  2. A Scale Profile with artifact sizes/object counts for this dataset.
  3. The RasterFlow workflow stages and resulting outputs:
  4. Implementation notes in Appendix — Technical Notes.

RasterFlow Execution Model for a Global-Scale GeoAI Pipeline

This pipeline runs on RasterFlow, Wherobots’ planetary-scale inference engine for Earth Intelligence. RasterFlow provides a reproducible way to run a global-scale GeoAI pipeline.

It is a geospatial workflow system backed by Flyte V2 and Ray. We chose Flyte because long-running global jobs need strong workflow semantics: versioned executions, retries, lineage, and reproducible reruns. We chose Ray Datasets because raster inference and vectorization are naturally blockwise workloads, and Ray gives us elastic parallelism and task streaming support to support the many small task problem.

The rasterflow_remote client exposes common GeoAI workflow steps while running in each organization’s isolated namespace. As a result, resource controls, failure containment, and reruns stay scoped to that organization.

We construct the client and load the model config for the PRUE model:

from rasterflow_remote.model_registry import get_model_registry_config, ModelRegistryEnum
from rasterflow_remote import RasterflowClient

client = RasterflowClient()

# see https://huggingface.co/wherobots/prue-pt2 for model details
cfg = get_model_registry_config(ModelRegistryEnum.HUGGINGFACE, "wherobots/prue-pt2")

Build Mosaic

build_mosaic converts raw satellite scenes into analysis-ready feature mosaics on a shared grid.

For the FTW production run, this stage computes planting and harvest composites, writes feature COGs, and then materializes a global feature mosaic that can be stacked with model outputs. We persist that global feature product in Zarr because chunked, cloud-native storage lets us read small spatial windows directly from object storage during validation, inference, and downstream analytics.

from rasterflow_remote import DatasetEnum
from datetime import datetime

mosaic = client.build_mosaic(
    datasets=[DatasetEnum.S2_MED_PLANTING, DatasetEnum.S2_MED_HARVEST],
    aoi="https://github.com/fieldsoftheworld/ftw-inference-app/blob/main/src/data/s2-grid.json",
    start=datetime(2023, 1, 1),
    end=datetime(2025, 1, 1),
    crs_epsg=4326,
)

For implementation details, see Appendix: Feature COG and Appendix: GDAL notes.

Inspect the published feature mosaic

The produced feature mosaic is published as a Zarr store on Source Cooperative. Therefore, we can inspect structure, bands, and a spatial subset directly.

import xarray as xr
import rasterix

mosaic_ds = xr.open_zarr(
    "https://data.source.coop/ftw/global-data/features/zarr/alpha/global.zarr"
).pipe(rasterix.assign_index)
mosaic_ds
<xarray.Dataset> Size: 502TB
Dimensions:      (time: 2, band: 10, y: 1566049, x: 4007517)
Coordinates:
  * time         (time) datetime64[ns] 16B 2024-01-01 2025-01-01
  * band         (band) object 80B 's2med_harvest:B02' ... 's2med_planting:N_...
  * y            (y) float64 13MB 83.75 83.75 83.75 ... -56.93 -56.93 -56.93
  * x            (x) float64 32MB -180.0 -180.0 -180.0 ... 180.0 180.0 180.0
    spatial_ref  int64 8B ...
Data variables:
    variables    (time, band, y, x) float32 502TB dask.array<chunksize=(1, 1, 4096, 4096), meta=np.ndarray>
Indexes:
  ┌ x        RasterIndex (crs=None)
  └ y

Persisting an intermediate global Zarr mosaic lets us query any region without reprojecting or resampling.

Training data for the PRUE model is also aligned to EPSG:4326, so feature and prediction stores remain grid-compatible for side-by-side analysis. For data layout details, see Appendix: Zarr internals.

For the examples below, we use the same Japan AOI shown later in the PMTiles preview so the raster and vector artifacts are easy to compare. For more information on xarray, see Appendix: Xarray notes.

import matplotlib.pyplot as plt
import numpy as np

bbox = {
    "x": slice(140.24, 140.34),
    "y": slice(37.36, 37.26),
}
subset = mosaic_ds.sel(x=bbox["x"], y=bbox["y"])
geo_aspect = 1 / np.cos(np.deg2rad(float(subset.y.mean())))

fig, axes = plt.subplots(1, 2, figsize=(9, 9), constrained_layout=True)
for ax, band_prefix, title in [
    (axes[0], "s2med_planting", "Planting RGB (PMTiles AOI, 2025)"),
    (axes[1], "s2med_harvest", "Harvest RGB (PMTiles AOI, 2025)"),
]:
    subset["variables"].sel(
        time="2025-01-01",
        band=[f"{band_prefix}:B04", f"{band_prefix}:B03", f"{band_prefix}:B02"],
    ).plot.imshow(ax=ax, robust=True)
    ax.set_aspect(geo_aspect)
    ax.set_title(title)

plt.show()

See additional information in Appendix: Zarr internals and Appendix: Xarray notes.

Predict Mosaic

predict_mosaic runs global model inference over the feature mosaic and writes one prediction Zarr store.

You provide the model config, and the workflow executes inference across the global grid. Keeping the result in Zarr preserves the same chunked access pattern as the feature store, so validation and downstream vectorization can stay blockwise and cloud-native instead of stitching regional outputs back together later.

predictions = client.predict_mosaic(
    store="https://data.source.coop/ftw/global-data/features/zarr/alpha/global.zarr",
    model_path=cfg.model_path,
    patch_size=cfg.patch_size,
    clip_size=cfg.clip_size,
    device=cfg.device,
    features=cfg.features,
    labels=cfg.labels,
    actor=cfg.actor,
    max_batch_size=cfg.max_batch_size,
    merge_mode=cfg.merge_mode,
)

For internals and scheduling details, see Appendix: config, Appendix: Ray usage, and Appendix: distributed inference.

For a similar inference step using text-prompted detection instead of segmentation models, see how Segment Anything 3 runs on aerial imagery with RasterFlow.

Inspect the published prediction mosaic

The prediction Zarr is also published on Source Cooperative. Let’s verify shape, bands, and value ranges.

The shape, bounds, and time remain aligned with the feature mosaic; however, the bands now represent predicted classes.

pred_ds = xr.open_zarr(
    "https://data.source.coop/ftw/global-data/predictions/zarr/alpha/global.zarr"
).pipe(rasterix.assign_index)
pred_ds
<xarray.Dataset> Size: 151TB
Dimensions:      (time: 2, band: 3, y: 1566049, x: 4007517)
Coordinates:
  * time         (time) datetime64[ns] 16B 2024-01-01 2025-01-01
  * band         (band) <U20 240B 'non_field_background' ... 'field_boundaries'
  * y            (y) float64 13MB 83.75 83.75 83.75 ... -56.93 -56.93 -56.93
  * x            (x) float64 32MB -180.0 -180.0 -180.0 ... 180.0 180.0 180.0
    spatial_ref  int64 8B ...
Data variables:
    variables    (time, band, y, x) float32 151TB dask.array<chunksize=(1, 1, 2048, 2048), meta=np.ndarray>
Indexes:
  ┌ x        RasterIndex (crs=None)
  └ y

Let’s plot the three labels for the same PMTiles-area AOI so the raster predictions line up with the vector preview later in the post:

import numpy as np

bbox = {
    "x": slice(140.24, 140.34),
    "y": slice(37.36, 37.26),
}
pred_subset = pred_ds.sel(x=bbox["x"], y=bbox["y"])
geo_aspect = 1 / np.cos(np.deg2rad(float(pred_subset.y.mean())))

grid = pred_subset["variables"].plot.imshow(col="band", row="time", figsize=(12, 8))
for ax in grid.axes.flat:
    ax.set_aspect(geo_aspect)

plt.show()

Zarr metadata for those curious is available in Appendix: Zarr internals.

Vectorize Mosaic

vectorize_mosaic converts prediction rasters into field-boundary GeoParquet outputs.

You specify feature bands, threshold, and method. Then the workflow writes distributed vector outputs for analytics and downstream publishing. We write GeoParquet because it keeps the result columnar, splittable, and easy to consume from batch analytics systems.

Conceptually, this output is a distributed geospatial table rather than an array: each row is a polygonized field candidate, the geometry column holds the boundary, and the remaining columns carry the scalar attributes you want to inspect in GeoPandas or query later in SedonaDB and WherobotsDB.

For distributed implementation details, see Appendix: vectorization.

from rasterflow_remote.data_models import VectorizeMethodEnum, SemSegRasterioConfig

vectors = client.vectorize_mosaic(
    store="https://data.source.coop/ftw/global-data/predictions/zarr/alpha/global.zarr",
    features=["field"],
    threshold=0.5,
    vectorize_method=VectorizeMethodEnum.SEMANTIC_SEGMENTATION_RASTERIO,
    vectorize_config=SemSegRasterioConfig(stats=False, medial_skeletonize=False),
)

A lightweight way to inspect that schema locally is:

import geopandas as gpd

vector_preview = gpd.read_parquet(vectors.uri)
vector_preview.head(3)

That gives you a normal GeoDataFrame view of the vector output: one geometry column plus the per-row attributes emitted by the vectorization stage.

Implementation details for distributed/blockwise vectorization are in Appendix: vectorization.

PMTiles

PMTiles packages vector outputs for fast, interactive map delivery. We use it here because a single archive is easy to publish, cache, and stream into browser maps without standing up a tile service.

We also support generating PMTiles from large GeoParquet outputs directly in WherobotsDB using our scalable PMTiles generator workflow, as described here: How to Generate PMTiles for Overture Maps with WherobotsDB VTiles.

Scale Profile

This section summarizes the scale of each pipeline step and the size of the generated outputs. Understanding the scale of this production run will help when we discuss design choices later in this blog post.

ArtifactPipeline StepS3 LocationSizeObject CountAdditional Scale Signal
Feature COGsbuild_mosaics3://us-west-2.opendata.source.coop/tge-labs/ftw-global-data/features/cogs/153 TB90,918Each COG is a median composite from ~5-10 Sentinel-2 scenes per pixel
Feature Zarr mosaicbuild_mosaics3://us-west-2.opendata.source.coop/tge-labs/ftw-global-data/features/zarr/alpha/global.zarr150 TB363,9997,499,140 logical chunks (sharded to keep object count manageable)
Prediction Zarr mosaicpredict_mosaics3://us-west-2.opendata.source.coop/tge-labs/ftw-global-data/predictions/zarr/alpha/global.zarr45 TB84,8778,982,630 logical chunks (also sharded)
Vector outputs (GeoParquet)vectorize_mosaics3://us-west-2.opendata.source.coop/tge-labs/ftw-global-data/predictions/vectors/alpha/results/675 GB1,0008,217,195,679 rows

Aggregate data output (above artifacts): ~348.7 TB across 540,794 objects.

RasterFlow summary

You can reproduce this end-to-end global-scale GeoAI pipeline with three API calls:

from datetime import datetime

from rasterflow_remote import RasterflowClient, DatasetEnum
from rasterflow_remote.model_registry import get_model_registry_config, ModelRegistryEnum
from rasterflow_remote.data_models import VectorizeMethodEnum, SemSegRasterioConfig

client = RasterflowClient()
cfg = get_model_registry_config(ModelRegistryEnum.HUGGINGFACE, "wherobots/prue-pt2")

mosaic = client.build_mosaic(
    datasets=[DatasetEnum.S2_MED_PLANTING, DatasetEnum.S2_MED_HARVEST],
    aoi="https://github.com/fieldsoftheworld/ftw-inference-app/blob/main/src/data/s2-grid.json",
    start=datetime(2023, 1, 1),
    end=datetime(2025, 1, 1),
    crs_epsg=4326,
)

predictions = client.predict_mosaic(
    store=mosaic.uri,
    model_path=cfg.model_path,
    patch_size=cfg.patch_size,
    clip_size=cfg.clip_size,
    device=cfg.device,
    features=cfg.features,
    labels=cfg.labels,
    actor=cfg.actor,
    max_batch_size=cfg.max_batch_size,
    merge_mode=cfg.merge_mode,
)

vectors = client.vectorize_mosaic(
    store=predictions.uri,
    features=["field"],
    threshold=0.5,
    vectorize_method=VectorizeMethodEnum.SEMANTIC_SEGMENTATION_RASTERIO,
    vectorize_config=SemSegRasterioConfig(stats=False, medial_skeletonize=False),
)

In short, this global-scale GeoAI pipeline with RasterFlow starts from global feature mosaics, runs one global inference workflow, and publishes both analytics-ready and map-ready outputs.

If you want a hands-on starting point, sign up for the RasterFlow Private Preview today and try the FTW notebook in the Solution Gallery.

If your team is evaluating global-scale raster + vector production workflows, this is exactly what RasterFlow private preview is for.

For API details and additional examples, see the Rasterflow documentation.

Appendix — Technical Notes

Building Feature COGs

Internally, build_mosaic orchestrates four key steps:

  1. Seasonal DOY filtering — candidate scenes are filtered by day-of-year windows derived from latitude-aware seasonal heuristics.
  2. Quality masking — cloud/quality masks suppress invalid observations.
  3. Greedy scene selection — we iteratively pick scenes that maximize uncovered valid area so we reach broad coverage with a bounded scene count.
  4. Temporal compositing — selected scenes are collapsed into median composites for downstream model features.

The current DOY heuristic and greedy objective are intentionally conservative and production-stable, but there is room for improvement (for example, region-specific phenology priors, dynamic cloud climatology, and cost-aware objective tuning).

We also produce a COG index, which is used with GDAL’s GTI driver to build the underlying global mosaic.

import geopandas as gpd
import fsspec

with fsspec.open("https://data.source.coop/ftw/global-data/features/cogs/alpha/index.parquet") as f:
    index = gpd.read_parquet(f)
index.head(3)
tile_id geometry time dataset url
0 01FBE POLYGON ((180 -50.59941, 178.76332 -50.5626, 1… 2024-01-01 00:00:00+00:00 s2med_planting s3://us-west-2.opendata.source.coop/tge-labs/f…
1 01FBE POLYGON ((180 -50.59941, 178.76332 -50.5626, 1… 2025-01-01 00:00:00+00:00 s2med_planting s3://us-west-2.opendata.source.coop/tge-labs/f…
2 01FBF POLYGON ((180 -49.70001, 178.84184 -49.66599, … 2024-01-01 00:00:00+00:00 s2med_planting s3://us-west-2.opendata.source.coop/tge-labs/f…

Mosaicking notes (GTI + Ray)

After feature COGs are written, we build a global virtual mosaic index and then materialize aligned arrays on the target grid. This relies on GDAL’s GTI driver as the index layer and mosaicking.

Grid configuration for this run:

  • CRS: EPSG:4326
  • Resolution: 8.983119e-5 degrees (~10 m at equator)
  • Resampling: cubic

The GTI-backed approach keeps COG indexing separate from downstream chunk/block execution and maps cleanly to distributed scheduling.

Zarr internals

Zarr is the storage layer that makes the global feature and prediction mosaics usable as products rather than just intermediate files.

Two layout choices matter here:

  • Logical chunks are the units we want readers and compute stages to address.
  • Shards are the larger storage objects we actually write to object storage.

We split them on purpose. Small logical chunks keep regional reads and retry boundaries precise, while larger shards prevent the store from exploding into billions of tiny objects. Larger shards also lower overall S3 PUT and LIST costs and reduce request-rate pressure.

For the feature store, the logical chunk is (1, 1, 4096, 4096) and shards are written at (1, 1, 12288, 12288), so each object packs a 3 x 3 tile of logical chunks. For the prediction store, the logical chunk is (1, 1, 2048, 2048) and shards are written at (1, 3, 8192, 8192), so each object packs all three class bands across a 4 x 4 spatial tile of logical chunks.

The tradeoff is deliberate: larger shards improve object-store efficiency and reduce listing overhead, but smaller logical chunks still give us better partial reads, safer retries, and more control over Ray execution geometry.

The feature Zarr mosaic metadata is as follows:

import zarr

group = zarr.open("https://data.source.coop/ftw/global-data/features/zarr/alpha/global.zarr")
group["variables"].metadata.__dict__
{'shape': (2, 10, 1566049, 4007517),
 'data_type': Float32(endianness='little'),
 'chunk_grid': RegularChunkGrid(chunk_shape=(1, 1, 12288, 12288)),
 'chunk_key_encoding': DefaultChunkKeyEncoding(separator='/'),
 'codecs': (ShardingCodec(chunk_shape=(1, 1, 4096, 4096), codecs=(BytesCodec(endian=<Endian.little: 'little'>), ZstdCodec(level=0, checksum=False)), index_codecs=(BytesCodec(endian=<Endian.little: 'little'>), Crc32cCodec()), index_location=<ShardingCodecIndexLocation.end: 'end'>),),
 'dimension_names': ('time', 'band', 'y', 'x'),
 'fill_value': np.float32(nan),
 'attributes': {'coordinates': 'spatial_ref', '_FillValue': 'AAAAAAAA+H8='},
 'storage_transformers': (),
 'extra_fields': {}}

The prediction zarr mosaic metadata is as follows:

group = zarr.open("https://data.source.coop/ftw/global-data/predictions/zarr/alpha/global.zarr")
group["variables"].metadata.__dict__
{'shape': (2, 3, 1566049, 4007517),
 'data_type': Float32(endianness='little'),
 'chunk_grid': RegularChunkGrid(chunk_shape=(1, 3, 8192, 8192)),
 'chunk_key_encoding': DefaultChunkKeyEncoding(separator='/'),
 'codecs': (ShardingCodec(chunk_shape=(1, 1, 2048, 2048), codecs=(BytesCodec(endian=<Endian.little: 'little'>), ZstdCodec(level=0, checksum=False)), index_codecs=(BytesCodec(endian=<Endian.little: 'little'>), Crc32cCodec()), index_location=<ShardingCodecIndexLocation.end: 'end'>),),
 'dimension_names': ('time', 'band', 'y', 'x'),
 'fill_value': np.float32(nan),
 'attributes': {'coordinates': 'spatial_ref', '_FillValue': 'AAAAAAAA+H8='},
 'storage_transformers': (),
 'extra_fields': {}}

Xarray notes

We use rasterix to attach analytical coordinates lazily so opening the global feature store does not require eagerly reading large coordinate vectors every time.

Why this matters:

  • x length: 1,566,049
  • y length: 4,007,517
  • At float64 precision, coordinates alone are roughly (1,566,049 + 4,007,517) * 8 ~= 45 MB before reading any model features.

Operationally, our xarray pattern is:

  1. Open lazily from object storage.
  2. Slice first (.sel) to bound I/O.
  3. Materialize only when plotting/validating.

This keeps exploratory analysis responsive while preserving direct comparability between feature and prediction stores on the same grid.

The Inference Config

The InferenceConfig is the contract between model packaging and distributed execution. It tells RasterFlow how to read features, how large each inference window should be, what runtime to launch, and how overlapping predictions should be merged back into the output mosaic.

In practice, the fields fall into a few groups:

  • model_path and actor select the model artifact and the inference runtime implementation.
  • patch_size and clip_size define the read window and how much border area is discarded to suppress edge artifacts.
  • features and labels define the exact tensor interface between the Zarr store and the model.
  • device and max_batch_size control throughput versus memory pressure on the target worker.
  • merge_mode defines how overlapping patch predictions are blended into the final raster.

from rasterflow_remote.data_models import MergeModeEnum, InferenceActorEnum

{
    # path to the pt2 archive on hugging face
    'model_path': 'https://huggingface.co/wherobots/prue-pt2/resolve/main/prue-efnetb7.pt2',
    # an enum used to specify how to handle the model for inference
    'actor': InferenceActorEnum.SEMANTIC_SEGMENTATION_PYTORCH,
    # patch size determines the size of the inputs provided to the model
    'patch_size': 256,
    # clip size determines the size we clip off the borders to reduce edge effects during inference
    'clip_size': 32,
    # device to run inference on, e.g. 'cuda' or 'cpu'
    'device': 'cuda',
    # list of features in order as required by the model. These names map to the input Zarr band labels.
    'features': ['s2med_harvest:B04',
    's2med_harvest:B03',
    's2med_harvest:B02',
    's2med_harvest:B08',
    's2med_planting:B04',
    's2med_planting:B03',
    's2med_planting:B02',
    's2med_planting:B08'],
    # the named labels for the predicted classes. These will be the output band labels in the output Zarr.
    'labels': ['non_field_background', 'field', 'field_boundaries'],
    # the maximum batch size assuming our default A10 GPU with 24GB of memory.
    'max_batch_size': 128,
    # the method to use when merging overlapping predictions to reduce edge effects at the patch level
    'merge_mode': MergeModeEnum.WEIGHTED_AVERAGE
}

How Ray is used for inference

We chose Ray as the data-plane engine because the workload is embarrassingly parallel at block level, but still needs data-aware scheduling and actor pools for GPU inference.

Ray is used as a bounded block-processing engine over sharded Zarr storage.

Key mapping model:

  • Logical chunks define storage math and indexability.
  • Shards reduce object count by packing multiple chunks per object.
  • Ray blocks define execution granularity and may span multiple chunks.

This decoupling lets us tune compute batch shape without rewriting storage layout. In the data pipeline, Ray Data uses Arrow-backed transport so tabular metadata/state can move across stages with minimal copying.

Practical takeaway: throughput scales with actor parallelism while per-task memory remains tied to block geometry, not global mosaic size.

Distributed inference (wherobots-rasterflow)

The InferenceConfig above feeds directly into block planning, actor setup, and overlap-aware merging. Internally, predict_mosaic orchestrates three phases:

  1. Block planning — derive execution blocks from chunk metadata and resource limits.
  2. Read/Infer/Write pipeline — execute overlap-aware reads, actor inference, and deterministic writes.
  3. Retry-safe commit — persist block outputs with stable target regions.
# conceptual : chunk/shard metadata -> Ray blocks

chunk_meta = read_zarr_chunk_metadata(store)
blocks = plan_ray_blocks(chunk_meta, target_rows_per_block)

ray_ds = ray.data.from_items(blocks)
result = (
    ray_ds
    .map(read_zarr_block_with_overlap)
    .map(run_inference_actor_pool)
    .map(clip_and_write_block)
)

Our distributed approach to vectorizing

vectorize_mosaic operates at block level so geometry generation scales linearly with partition count rather than requiring one global polygonization pass.

High-level flow:

  1. Read prediction blocks from Zarr.
  2. Apply thresholding per block.
  3. Polygonize per block.
  4. Emit GeoParquet partitions and merge metadata.

This keeps memory bounded and allows retries/reprocessing for individual partitions.

Sign up for RasterFlow Private Preview

Key takeaways

  • Taylor Geospatial partnered with Wherobots to run the PRUE model on RasterFlow for the 2024–2025 Fields of the World global release. PRUE turns seasonal Sentinel-2 composites into field and field_boundaries predictions; RasterFlow runs data access, compositing, inference, and vectorization as one workflow.
  • The pipeline is three API calls: build_mosaic (planting and harvest Sentinel-2 median composites, 2023-01-01 to 2025-01-01, EPSG:4326), predict_mosaic, and vectorize_mosaic (field band, threshold 0.5). RasterFlow is backed by Flyte V2 and Ray, running in each organization's isolated namespace.
  • Scale profile: feature COGs 153 TB (90,918 objects); feature Zarr mosaic 150 TB (363,999 objects, 7,499,140 logical chunks); prediction Zarr 45 TB (84,877 objects, 8,982,630 logical chunks); vector GeoParquet 675 GB (1,000 objects, 8,217,195,679 rows). Aggregate output ~348.7 TB across 540,794 objects.
  • Zarr layout splits logical chunks from shards on purpose: feature logical chunks (1, 1, 4096, 4096) packed into (1, 1, 12288, 12288) shards; prediction logical chunks (1, 1, 2048, 2048) packed into (1, 3, 8192, 8192) shards covering all three class bands. That keeps regional reads precise without exploding object count.

Take-aways from the 2026 Geospatial Embeddings Workshop at Clark University

EO Embeddings have a lot of excitement (and hype) around them. My social media is filled with content about EO embeddings and GeoML communities like the TorchGeo Slack and GitHub are focused on onboarding “foundation models” and associated assets like training datasets. Indie hackers like Christopher Ren are building useful applications for exploring and comparing embeddings, see GeoVibes.

This work is all fantastic. At the same time, I’m still left with a sense that fundamental issues around access and enablement have not been addressed, and that the performance and fitness for use of EO embedding products has not been adequately communicated.

In early March, I got together with industry and academic experts at the Geospatial Embeddings Workshop at Clark University to start addressing this. We discussed standards for storage, documentation, and cataloging and implemented some of these ideas, which can be found at github.com/geo-embeddings.

Workshop Outcomes

We now have a site to collect best practices, document standards, and showcase tutorials for EO Embeddings. Check it out at geoembeddings.org!

Some selected recommendations:

  • use collection formats for storing embeddings, Zarr v3 for regularly gridded data, GeoParquet for embeddings of sparsely collected data
  • use the new embedding STAC spec which communicate how embeddings were produced, fitness for use, and search and discovery metadata. A sibling convention for storing this same info within a Zarr is in the works.
  • take a look at an example Model Card that compiles metadata to document the model that produces embeddings: geoembeddings.org/model-card.html
  • Check out this tutorial for inspecting AEF embeddings with Xarray! Feel free to make your own tutorials and contribute them here. We’d love to collate examples of workflows on top of embeddings for similarity search, change detection, and fine-tuning, as well as showcase other embedding models and products.

These resources are a start, but they don’t solve everything. In the rest of this post, I want to highlight three barriers I think we still need to address to make geo embeddings truly impactful: the surprising cost of storing embeddings, unclear fitness for use, and the lack of publicized benchmarks.

Embedding storage can be expensive, take advantage of compression!

The first barrier to using embeddings is practical: storing embeddings can cost more than you might expect. While EO data can be really large, the embeddings generated can be even larger than the input. I didn’t really appreciate this simple fact given my background in detection and segmentation, where typically the model result is smaller than the input.

In recent model releases, EO models are trending towards flexibly accepting, but not requiring, multi modal inputs. If you have only Sentinel-2 imagery, you can generate embeddings for just Sentinel-2. But this wastes some capacity of the embedding generation model.

For example, OlmoEarth Nano generates 128 embedding dimensions regardless of how many sensors or timesteps you feed in. That means adding more sensors makes the embedding progressively smaller relative to the input. The table below compares embedding size to input size for a 512 × 512 block at 10 m resolution with 12 monthly timesteps, in float32 (values show the ratio of embedding size to input size):

Input stackBandsInput sizeNano Output (128-dim)Base Output (768-dim)
S2 only, 1 scene130.013 GiB9.6× larger57× larger
S2 only, 12 scenes130.152 GiB0.82×4.9× larger
S2 + S1 VV/VH, 12 scenes150.176 GiB0.71×4.3× larger
S2 + S1 VV/VH + Landsat, 12 scenes260.305 GiB0.41×2.5× larger

The Nano embedding only becomes smaller than the input once you have a full 12-scene time series. The Base model (768-dim, 0.750 GiB) is larger than the raw input in every scenario, so it is worth factoring this storage cost in and weighing it against the performance of the embeddings prior to scaling out and committing resources to a big run.

Fitness for use is often unknown

Even if you can afford to store embeddings, do you know if they’ll work for your problem?

EO Embeddings are often described as products of “foundation models”. Yet unlike foundation LLM models, which can carry out agentic tasks in thousands of different contexts at expert level, EO embedding models are much more restricted in the domains they can be successfully applied.

For example, today most EO embeddings are derived from medium resolution satellite imagery. Practitioners curious about embeddings are often disappointed to find that they are not able to use embeddings with high resolution imagery for detection tasks. Or, they may be limited to agricultural use cases, not be applicable to some geographic regions, etc.

Documenting and describing this fitness for use is often done in the research paper, but not clearly in the catalogue where the embeddings are hosted. And papers often leave out details on the sampling regime and how embeddings were tested. To address this, check out the resources linked above to better document your embedding model or an existing embedding product!

Lack of publicized benchmarks is a barrier to adoption

And we don’t yet have a shared way to answer questions about fitness for use. Model benchmarks, while imperfect and easily gamed, are essential for communicating what is worth the effort to try.

In the LLM space, benchmarks like MMLU and SWE-Bench have been useful signals for indicating which models are best. As they become saturated or too easy, new benchmarks arise to fill the gap.

I don’t see a similar pattern happening with EO embeddings or EO models in general. Benchmarks are typically done within a paper and how comprehensive it is governed by how much time, compute, and expertise the author had. I don’t think we as a community of researchers, practitioners, and end-users have much memory for these paper benchmarks.

I’d like to contribute to an effort toward a leaderboard for EO foundation models, which can host multiple benchmarks, aligning the community on which models, and which benchmark datasets, are useful and important.

To address this, get in touch if you’d like to compare embedding models to each other at useful scale so we can more effectively communicate performance of these models for specific use cases!

What’s Wherobots doing with embeddings and foundation models?

At the core of it, I think experimenting with embeddings can often feel difficult, and we’re trying to change that at Wherobots. Wherobots has applied AlphaEarth Foundations embeddings to change detection, agricultural monitoring, and field-level zonal statistics.

Before the workshop, we’d been hearing from many that they were curious to try foundation models on their specific problem, in order to increase price/performance of their workloads, improve model accuracy, and more easily solve more problems with less complicated workflows and expensive model fine-tuning.

To help here, we’ve onboarded a foundation model to our Model Hub, OlmoEarth Nano for embedding multi-spectral optical and radar imagery.

We publish our models on Huggingface in Pytorch 2 Archive format so that you can load and run these models using only Pytorch as a dependency. This can be helpful for testing and comparing embeddings outside of a larger ML pipeline.

If you need to scale out these models, check out RasterFlow. It’s a batch processing engine built for any scale of imagery you need to run inference on, from city scale, high resolution imagery to global, petabyte scale collections. I think RasterFlow could be a key enabler to comparing embeddings at the large spatial scale they are typically generated at.

If you’re experimenting with EO embeddings, contribute a tutorial to geoembeddings.org, contribute a Model Card for your cool new model, or just share what’s working and what’s not. We’re looking forward to lowering these barriers to EO derived insights with the community!

Key takeaways

  • In early March 2026, industry and academic experts met at Clark University to work on EO embedding standards for storage, documentation, and cataloging. Outcomes live at geoembeddings.org and github.com/geo-embeddings, including collection-format guidance, a STAC embedding spec, a Model Card example, and an AEF-with-Xarray tutorial.
  • Selected recommendations: Zarr v3 for regularly gridded embeddings, GeoParquet for sparsely collected embeddings, and the new embedding STAC spec for how embeddings were produced, fitness for use, and search metadata. A sibling Zarr convention is in the works.
  • Embedding storage can exceed input size. OlmoEarth Nano always emits 128 dimensions; Base emits 768. For a 512×512 block at 10 m with 12 monthly timesteps in float32, Nano is 9.6× larger than a single Sentinel-2 scene and only becomes smaller than the input with a full 12-scene time series. Base (0.750 GiB) is larger than the raw input in every scenario in the table.
  • Three remaining barriers: storage cost, undocumented fitness for use (most EO embeddings come from medium-resolution imagery and often fail high-res detection or some regions), and a lack of publicized, remembered benchmarks compared with LLM leaderboards. Wherobots onboarded OlmoEarth Nano to the Model Hub in PyTorch 2 Archive format and points to RasterFlow for scale-out.