From the Spokane firestorm to all of Washington: real-time wildfire monitoring for under $50 a pass Posted on August 11, 2026October 4, 2026 by Jia Yu 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 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. 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. 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. 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. 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 takeawaysOn 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.
Change Detection Using AlphaEarth Foundations (Part 2) Posted on May 6, 2026October 4, 2026 by Ben Pruden 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'...xarray.DatasetDimensions:time: 9band: 64y: 1859584x: 4009984Coordinates: (4)time(time)int322017 2018 2019 … 2023 2024 2025_zarrs :{‘description’: ‘This array was created with zarrs’, ‘repository’: ‘https://github.com/zarrs/zarrs’, ‘version’: ‘0.23.0’}array([2017, 2018, 2019, 2020, 2021, 2022, 2023, 2024, 2025], dtype=int32)band(band)object‘A00’ ‘A01’ ‘A02’ … ‘A62’ ‘A63’_zarrs :{‘description’: ‘This array was created with zarrs’, ‘repository’: ‘https://github.com/zarrs/zarrs’, ‘version’: ‘0.23.0’}array(['A00', 'A01', 'A02', 'A03', 'A04', 'A05', 'A06', 'A07', 'A08', 'A09', 'A10', 'A11', 'A12', 'A13', 'A14', 'A15', 'A16', 'A17', 'A18', 'A19', 'A20', 'A21', 'A22', 'A23', 'A24', 'A25', 'A26', 'A27', 'A28', 'A29', 'A30', 'A31', 'A32', 'A33', 'A34', 'A35', 'A36', 'A37', 'A38', 'A39', 'A40', 'A41', 'A42', 'A43', 'A44', 'A45', 'A46', 'A47', 'A48', 'A49', 'A50', 'A51', 'A52', 'A53', 'A54', 'A55', 'A56', 'A57', 'A58', 'A59', 'A60', 'A61', 'A62', 'A63'], dtype=object)y(y)float6483.69 83.69 83.69 … -83.36 -83.36_zarrs :{‘description’: ‘This array was created with zarrs’, ‘repository’: ‘https://github.com/zarrs/zarrs’, ‘version’: ‘0.23.0’}array([ 83.68566 , 83.685571, 83.685481, ..., -83.362579, -83.362669, -83.362759], shape=(1859584,))x(x)float64-180.0 -180.0 … 180.2 180.2_zarrs :{‘description’: ‘This array was created with zarrs’, ‘repository’: ‘https://github.com/zarrs/zarrs’, ‘version’: ‘0.23.0’}array([-179.999955, -179.999865, -179.999775, ..., 180.221119, 180.221209, 180.221299], shape=(4009984,))Data variables: (1)embeddings(time, band, y, x)int8dask.array<chunksize=(1, 64, 256, 256), meta=np.ndarray>_zarrs :{‘description’: ‘This array was created with zarrs’, ‘repository’: ‘https://github.com/zarrs/zarrs’, ‘version’: ‘0.23.0’} Array Chunk Bytes 3.81 PiB 4.00 MiB Shape (9, 64, 1859584, 4009984) (1, 64, 256, 256) Dask graph 1024049664 chunks in 2 graph layers Data type int8 numpy.ndarray 9 1 4009984 1859584 64 Attributes: (15)proj:code :EPSG:4326spatial:dimensions :[‘y’, ‘x’]spatial:transform :[8.983111749910169e-05, 0.0, -180.0, 0.0, -8.983111749910169e-05, 83.68570533713473]spatial:transform_type :affinespatial:bbox :[-180.0, -83.36280346631479, 180.2213438735178, 83.68570533713473]spatial:shape :[1859584, 4009984]spatial:registration :pixelgeoemb:type :pixelgeoemb:dimensions :64geoemb:model :https://developers.google.com/earth-engine/datasets/catalog/GOOGLE_SATELLITE_EMBEDDING_V1_ANNUALgeoemb:source_data :https://source.coop/tge-labs/aef/v1/annual/geoemb:data_type :int8geoemb:gsd :8.983111749910169e-05geoemb:quantization :{‘method’: ‘signed_square’, ‘original_dtype’: ‘float32’, ‘quantized_dtype’: ‘int8’, ‘formula’: ‘(x / 127.5) ** 2 * sign(x)’, ‘valid_range’: [-127, 127], ‘nodata’: -128}zarr_conventions :[{‘uuid’: ‘f17cb550-5864-4468-aeb7-f3180cfb622f’, ‘name’: ‘proj:’, ‘description’: ‘Coordinate reference system information for geospatial data’, ‘spec_url’: ‘https://github.com/zarr-experimental/geo-proj/blob/v1/README.md’, ‘schema_url’: ‘https://raw.githubusercontent.com/zarr-experimental/geo-proj/refs/tags/v1/schema.json’}, {‘uuid’: ‘689b58e2-cf7b-45e0-9fff-9cfc0883d6b4’, ‘name’: ‘spatial:’, ‘description’: ‘Spatial coordinate information’, ‘spec_url’: ‘https://github.com/zarr-conventions/spatial/blob/v1/README.md’, ‘schema_url’: ‘https://raw.githubusercontent.com/zarr-conventions/spatial/refs/tags/v1/schema.json’}, {‘uuid’: ’61c12cc5-0e28-4056-999a-480cf3fb7e4c’, ‘name’: ‘geoemb:’, ‘description’: ‘Geo-embeddings metadata for machine learning embeddings stored in Zarr’, ‘spec_url’: ‘https://github.com/geo-embeddings/embeddings-zarr-convention/tree/add-convention’}] 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: run_mosaics_change on the RasterFlow Remote client for computing difference and LOO medoid scores over time build_zarr_multiscales for producing a visualization-ready Zarr mosaic 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)=argminei∈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'...xarray.DatasetDimensions:time: 9band: 2y: 44800x: 78336Coordinates: (4)time(time)int322017 2018 2019 … 2023 2024 2025_zarrs :{‘description’: ‘This array was created with zarrs’, ‘repository’: ‘https://github.com/zarrs/zarrs’, ‘version’: ‘0.23.0’}array([2017, 2018, 2019, 2020, 2021, 2022, 2023, 2024, 2025], dtype=int32)band(band)object‘difference_alpha_earth_cosine_d…array(['difference_alpha_earth_cosine_distance_robust_time_zscore', 'loo_medoid_alpha_earth_cosine_distance_robust_time_zscore'], dtype=object)y(y)float6441.0 41.0 41.0 … 36.98 36.98_zarrs :{‘description’: ‘This array was created with zarrs’, ‘repository’: ‘https://github.com/zarrs/zarrs’, ‘version’: ‘0.23.0’}array([41.003663, 41.003573, 41.003483, ..., 36.979498, 36.979408, 36.979318], shape=(44800,))x(x)float64-109.1 -109.1 … -102.0 -102.0_zarrs :{‘description’: ‘This array was created with zarrs’, ‘repository’: ‘https://github.com/zarrs/zarrs’, ‘version’: ‘0.23.0’}array([-109.077928, -109.077839, -109.077749, ..., -102.041188, -102.041098, -102.041008], shape=(78336,))Data variables: (1)embeddings(time, band, y, x)float32dask.array<chunksize=(1, 1, 256, 256), meta=np.ndarray>_zarrs :{‘description’: ‘This array was created with zarrs’, ‘repository’: ‘https://github.com/zarrs/zarrs’, ‘version’: ‘0.23.0’} Array Chunk Bytes 235.33 GiB 256.00 kiB Shape (9, 2, 44800, 78336) (1, 1, 256, 256) Dask graph 963900 chunks in 2 graph layers Data type float32 numpy.ndarray 9 1 78336 44800 2 Attributes: (15)proj:code :EPSG:4326spatial:dimensions :[‘y’, ‘x’]spatial:transform :[8.983111749910169e-05, 0.0, -180.0, 0.0, -8.983111749910169e-05, 83.68570533713473]spatial:transform_type :affinespatial:bbox :[-180.0, -83.36280346631479, 180.2213438735178, 83.68570533713473]spatial:shape :[1859584, 4009984]spatial:registration :pixelgeoemb:type :pixelgeoemb:dimensions :64geoemb:model :https://developers.google.com/earth-engine/datasets/catalog/GOOGLE_SATELLITE_EMBEDDING_V1_ANNUALgeoemb:source_data :https://source.coop/tge-labs/aef/v1/annual/geoemb:data_type :int8geoemb:gsd :8.983111749910169e-05geoemb:quantization :{‘method’: ‘signed_square’, ‘original_dtype’: ‘float32’, ‘quantized_dtype’: ‘int8’, ‘formula’: ‘(x / 127.5) ** 2 * sign(x)’, ‘valid_range’: [-127, 127], ‘nodata’: -128}zarr_conventions :[{‘uuid’: ‘f17cb550-5864-4468-aeb7-f3180cfb622f’, ‘name’: ‘proj:’, ‘description’: ‘Coordinate reference system information for geospatial data’, ‘spec_url’: ‘https://github.com/zarr-experimental/geo-proj/blob/v1/README.md’, ‘schema_url’: ‘https://raw.githubusercontent.com/zarr-experimental/geo-proj/refs/tags/v1/schema.json’}, {‘uuid’: ‘689b58e2-cf7b-45e0-9fff-9cfc0883d6b4’, ‘name’: ‘spatial:’, ‘description’: ‘Spatial coordinate information’, ‘spec_url’: ‘https://github.com/zarr-conventions/spatial/blob/v1/README.md’, ‘schema_url’: ‘https://raw.githubusercontent.com/zarr-conventions/spatial/refs/tags/v1/schema.json’}, {‘uuid’: ’61c12cc5-0e28-4056-999a-480cf3fb7e4c’, ‘name’: ‘geoemb:’, ‘description’: ‘Geo-embeddings metadata for machine learning embeddings stored in Zarr’, ‘spec_url’: ‘https://github.com/geo-embeddings/embeddings-zarr-convention/tree/add-convention’}] 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 ...xarray.DataTree/0(6)Dimensions:time: 9band: 2y: 44800x: 78336Coordinates: (4)time(time)int322017 2018 2019 … 2023 2024 2025_zarrs :{‘description’: ‘This array was created with zarrs’, ‘repository’: ‘https://github.com/zarrs/zarrs’, ‘version’: ‘0.23.0’}array([2017, 2018, 2019, 2020, 2021, 2022, 2023, 2024, 2025], dtype=int32)band(band)StringDType()‘difference_alpha_earth_cosine_d…array(['difference_alpha_earth_cosine_distance_robust_time_zscore','loo_medoid_alpha_earth_cosine_distance_robust_time_zscore'],dtype=StringDType())y(y)float6441.0 41.0 41.0 … 36.98 36.98units :metersstandard_name :projection_y_coordinatearray([41.003663, 41.003573, 41.003483, ..., 36.979498, 36.979408, 36.979318],shape=(44800,))x(x)float64-109.1 -109.1 … -102.0 -102.0units :metersstandard_name :projection_x_coordinatearray([-109.077928, -109.077839, -109.077749, ..., -102.041188, -102.041098,-102.041008], shape=(78336,))Data variables: (2)embeddings(time, band, y, x)float32…raster:histogram :[{‘count’: 256, ‘min’: -453.6422424316406, ‘max’: 1552.254150390625, ‘buckets’: [1, 0, 0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 0, 0, 0, 3, 0, 0, 0, 0, 1, 1, 0, 0, 3, 0, 4, 5, 7, 2, 4, 5, 2, 9, 12, 19, 15, 23, 24, 28, 45, 67, 80, 103, 167, 301, 411, 731, 1247, 2265, 4825, 10898, 30131, 105677, 586428, 9241259, 21155882067, 6458720426, 141842195, 20581637, 6126534, 2475655, 1188362, 635136, 369947, 226262, 143658, 92272, 62053, 42110, 30694, 21947, 16372, 12457, 9650, 7272, 5705, 4679, 3559, 2871, 2342, 1962, 1666, 1430, 1127, 903, 790, 680, 636, 482, 432, 339, 332, 295, 258, 210, 170, 135, 170, 165, 124, 93, 89, 63, 81, 76, 55, 76, 63, 38, 53, 44, 31, 28, 19, 40, 24, 17, 25, 27, 19, 15, 17, 19, 18, 10, 10, 10, 4, 10, 7, 7, 4, 5, 8, 3, 6, 3, 1, 3, 6, 1, 4, 4, 0, 4, 2, 9, 3, 0, 3, 1, 2, 0, 8, 3, 1, 1, 0, 8, 0, 3, 4, 5, 0, 0, 0, 3, 2, 2, 2, 1, 3, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 2, 0, 0, 0, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1], ‘index’: {‘band’: 0}}, {‘count’: 256, ‘min’: -2419.623046875, ‘max’: 16685.6171875, ‘buckets’: [2, 0, 0, 0, 3, 0, 2, 0, 1, 3, 4, 2, 1, 1, 2, 1, 2, 6, 2, 6, 7, 10, 11, 12, 21, 53, 83, 165, 461, 1381, 7820, 331440, 31261242551, 10735198, 734657, 158052, 53459, 22328, 11132, 6140, 3758, 2266, 1447, 1093, 731, 612, 434, 326, 236, 189, 171, 139, 93, 106, 91, 63, 54, 53, 33, 21, 16, 15, 25, 14, 18, 22, 15, 5, 17, 14, 14, 11, 5, 6, 11, 1, 12, 3, 5, 0, 2, 3, 1, 3, 0, 0, 2, 7, 1, 0, 5, 0, 2, 0, 0, 1, 3, 0, 2, 3, 0, 0, 1, 0, 0, 0, 0, 1, 2, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 0, 1, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1], ‘index’: {‘band’: 1}}][63170150400 values with dtype=float32]spatial_ref()int32…crs_wkt :GEOGCRS[“WGS 84”,ENSEMBLE[“World Geodetic System 1984 ensemble”,MEMBER[“World Geodetic System 1984 (Transit)”],MEMBER[“World Geodetic System 1984 (G730)”],MEMBER[“World Geodetic System 1984 (G873)”],MEMBER[“World Geodetic System 1984 (G1150)”],MEMBER[“World Geodetic System 1984 (G1674)”],MEMBER[“World Geodetic System 1984 (G1762)”],MEMBER[“World Geodetic System 1984 (G2139)”],MEMBER[“World Geodetic System 1984 (G2296)”],ELLIPSOID[“WGS 84”,6378137,298.257223563,LENGTHUNIT[“metre”,1]],ENSEMBLEACCURACY[2.0]],PRIMEM[“Greenwich”,0,ANGLEUNIT[“degree”,0.0174532925199433]],CS[ellipsoidal,2],AXIS[“geodetic latitude (Lat)”,north,ORDER[1],ANGLEUNIT[“degree”,0.0174532925199433]],AXIS[“geodetic longitude (Lon)”,east,ORDER[2],ANGLEUNIT[“degree”,0.0174532925199433]],USAGE[SCOPE[“Horizontal component of 3D system.”],AREA[“World.”],BBOX[-90,-180,90,180]],ID[“EPSG”,4326]]GeoTransform :-109.07797340998921 8.983111749216732e-05 0.0 41.00370749308155 0.0 -8.983111749927275e-05[1 values with dtype=int32]/1(6)Dimensions:time: 9band: 2y: 22400x: 39168Coordinates: (4)time(time)int322017 2018 2019 … 2023 2024 2025_zarrs :{‘description’: ‘This array was created with zarrs’, ‘repository’: ‘https://github.com/zarrs/zarrs’, ‘version’: ‘0.23.0’}array([2017, 2018, 2019, 2020, 2021, 2022, 2023, 2024, 2025], dtype=int32)band(band)StringDType()‘difference_alpha_earth_cosine_d…array(['difference_alpha_earth_cosine_distance_robust_time_zscore','loo_medoid_alpha_earth_cosine_distance_robust_time_zscore'],dtype=StringDType())y(y)float6441.0 41.0 41.0 … 36.98 36.98units :metersstandard_name :projection_y_coordinatearray([41.003618, 41.003438, 41.003258, ..., 36.979723, 36.979543, 36.979363],shape=(22400,))x(x)float64-109.1 -109.1 … -102.0 -102.0units :metersstandard_name :projection_x_coordinatearray([-109.077884, -109.077704, -109.077524, ..., -102.041412, -102.041232,-102.041053], shape=(39168,))Data variables: (2)embeddings(time, band, y, x)float32…[15792537600 values with dtype=float32]spatial_ref()int32…crs_wkt :GEOGCRS[“WGS 84”,ENSEMBLE[“World Geodetic System 1984 ensemble”,MEMBER[“World Geodetic System 1984 (Transit)”],MEMBER[“World Geodetic System 1984 (G730)”],MEMBER[“World Geodetic System 1984 (G873)”],MEMBER[“World Geodetic System 1984 (G1150)”],MEMBER[“World Geodetic System 1984 (G1674)”],MEMBER[“World Geodetic System 1984 (G1762)”],MEMBER[“World Geodetic System 1984 (G2139)”],MEMBER[“World Geodetic System 1984 (G2296)”],ELLIPSOID[“WGS 84”,6378137,298.257223563,LENGTHUNIT[“metre”,1]],ENSEMBLEACCURACY[2.0]],PRIMEM[“Greenwich”,0,ANGLEUNIT[“degree”,0.0174532925199433]],CS[ellipsoidal,2],AXIS[“geodetic latitude (Lat)”,north,ORDER[1],ANGLEUNIT[“degree”,0.0174532925199433]],AXIS[“geodetic longitude (Lon)”,east,ORDER[2],ANGLEUNIT[“degree”,0.0174532925199433]],USAGE[SCOPE[“Horizontal component of 3D system.”],AREA[“World.”],BBOX[-90,-180,90,180]],ID[“EPSG”,4326]]GeoTransform :-109.07797340998921 0.00017966223498433465 0.0 41.00370749308155 0.0 -0.0001796622349985455[1 values with dtype=int32]/2(6)Dimensions:time: 9band: 2y: 11200x: 19584Coordinates: (4)time(time)int322017 2018 2019 … 2023 2024 2025_zarrs :{‘description’: ‘This array was created with zarrs’, ‘repository’: ‘https://github.com/zarrs/zarrs’, ‘version’: ‘0.23.0’}array([2017, 2018, 2019, 2020, 2021, 2022, 2023, 2024, 2025], dtype=int32)band(band)StringDType()‘difference_alpha_earth_cosine_d…array(['difference_alpha_earth_cosine_distance_robust_time_zscore','loo_medoid_alpha_earth_cosine_distance_robust_time_zscore'],dtype=StringDType())y(y)float6441.0 41.0 41.0 … 36.98 36.98units :metersstandard_name :projection_y_coordinatearray([41.003528, 41.003169, 41.002809, ..., 36.980172, 36.979812, 36.979453],shape=(11200,))x(x)float64-109.1 -109.1 … -102.0 -102.0units :metersstandard_name :projection_x_coordinatearray([-109.077794, -109.077434, -109.077075, ..., -102.041861, -102.041502,-102.041143], shape=(19584,))Data variables: (2)embeddings(time, band, y, x)float32…[3948134400 values with dtype=float32]spatial_ref()int32…crs_wkt :GEOGCRS[“WGS 84”,ENSEMBLE[“World Geodetic System 1984 ensemble”,MEMBER[“World Geodetic System 1984 (Transit)”],MEMBER[“World Geodetic System 1984 (G730)”],MEMBER[“World Geodetic System 1984 (G873)”],MEMBER[“World Geodetic System 1984 (G1150)”],MEMBER[“World Geodetic System 1984 (G1674)”],MEMBER[“World Geodetic System 1984 (G1762)”],MEMBER[“World Geodetic System 1984 (G2139)”],MEMBER[“World Geodetic System 1984 (G2296)”],ELLIPSOID[“WGS 84”,6378137,298.257223563,LENGTHUNIT[“metre”,1]],ENSEMBLEACCURACY[2.0]],PRIMEM[“Greenwich”,0,ANGLEUNIT[“degree”,0.0174532925199433]],CS[ellipsoidal,2],AXIS[“geodetic latitude (Lat)”,north,ORDER[1],ANGLEUNIT[“degree”,0.0174532925199433]],AXIS[“geodetic longitude (Lon)”,east,ORDER[2],ANGLEUNIT[“degree”,0.0174532925199433]],USAGE[SCOPE[“Horizontal component of 3D system.”],AREA[“World.”],BBOX[-90,-180,90,180]],ID[“EPSG”,4326]]GeoTransform :-109.07797340998921 0.0003593244699686693 0.0 41.00370749308155 0.0 -0.000359324469997091[1 values with dtype=int32]/3(6)Dimensions:time: 9band: 2y: 5600x: 9792Coordinates: (4)time(time)int322017 2018 2019 … 2023 2024 2025_zarrs :{‘description’: ‘This array was created with zarrs’, ‘repository’: ‘https://github.com/zarrs/zarrs’, ‘version’: ‘0.23.0’}array([2017, 2018, 2019, 2020, 2021, 2022, 2023, 2024, 2025], dtype=int32)band(band)StringDType()‘difference_alpha_earth_cosine_d…array(['difference_alpha_earth_cosine_distance_robust_time_zscore','loo_medoid_alpha_earth_cosine_distance_robust_time_zscore'],dtype=StringDType())y(y)float6441.0 41.0 41.0 … 36.98 36.98units :metersstandard_name :projection_y_coordinatearray([41.003348, 41.00263 , 41.001911, ..., 36.98107 , 36.980351, 36.979633],shape=(5600,))x(x)float64-109.1 -109.1 … -102.0 -102.0units :metersstandard_name :projection_x_coordinatearray([-109.077614, -109.076895, -109.076177, ..., -102.04276 , -102.042041,-102.041322], shape=(9792,))Data variables: (2)embeddings(time, band, y, x)float32…[987033600 values with dtype=float32]spatial_ref()int32…crs_wkt :GEOGCRS[“WGS 84”,ENSEMBLE[“World Geodetic System 1984 ensemble”,MEMBER[“World Geodetic System 1984 (Transit)”],MEMBER[“World Geodetic System 1984 (G730)”],MEMBER[“World Geodetic System 1984 (G873)”],MEMBER[“World Geodetic System 1984 (G1150)”],MEMBER[“World Geodetic System 1984 (G1674)”],MEMBER[“World Geodetic System 1984 (G1762)”],MEMBER[“World Geodetic System 1984 (G2139)”],MEMBER[“World Geodetic System 1984 (G2296)”],ELLIPSOID[“WGS 84”,6378137,298.257223563,LENGTHUNIT[“metre”,1]],ENSEMBLEACCURACY[2.0]],PRIMEM[“Greenwich”,0,ANGLEUNIT[“degree”,0.0174532925199433]],CS[ellipsoidal,2],AXIS[“geodetic latitude (Lat)”,north,ORDER[1],ANGLEUNIT[“degree”,0.0174532925199433]],AXIS[“geodetic longitude (Lon)”,east,ORDER[2],ANGLEUNIT[“degree”,0.0174532925199433]],USAGE[SCOPE[“Horizontal component of 3D system.”],AREA[“World.”],BBOX[-90,-180,90,180]],ID[“EPSG”,4326]]GeoTransform :-109.07797340998921 0.0007186489399373386 0.0 41.00370749308155 0.0 -0.000718648939994182[1 values with dtype=int32]/4(6)Dimensions:time: 9band: 2y: 2800x: 4896Coordinates: (4)time(time)int322017 2018 2019 … 2023 2024 2025_zarrs :{‘description’: ‘This array was created with zarrs’, ‘repository’: ‘https://github.com/zarrs/zarrs’, ‘version’: ‘0.23.0’}array([2017, 2018, 2019, 2020, 2021, 2022, 2023, 2024, 2025], dtype=int32)band(band)StringDType()‘difference_alpha_earth_cosine_d…array(['difference_alpha_earth_cosine_distance_robust_time_zscore','loo_medoid_alpha_earth_cosine_distance_robust_time_zscore'],dtype=StringDType())y(y)float6441.0 41.0 41.0 … 36.98 36.98units :metersstandard_name :projection_y_coordinatearray([41.002989, 41.001552, 41.000114, ..., 36.982867, 36.981429, 36.979992],shape=(2800,))x(x)float64-109.1 -109.1 … -102.0 -102.0units :metersstandard_name :projection_x_coordinatearray([-109.077255, -109.075817, -109.07438 , ..., -102.044556, -102.043119,-102.041682], shape=(4896,))Data variables: (2)embeddings(time, band, y, x)float32…[246758400 values with dtype=float32]spatial_ref()int32…crs_wkt :GEOGCRS[“WGS 84”,ENSEMBLE[“World Geodetic System 1984 ensemble”,MEMBER[“World Geodetic System 1984 (Transit)”],MEMBER[“World Geodetic System 1984 (G730)”],MEMBER[“World Geodetic System 1984 (G873)”],MEMBER[“World Geodetic System 1984 (G1150)”],MEMBER[“World Geodetic System 1984 (G1674)”],MEMBER[“World Geodetic System 1984 (G1762)”],MEMBER[“World Geodetic System 1984 (G2139)”],MEMBER[“World Geodetic System 1984 (G2296)”],ELLIPSOID[“WGS 84”,6378137,298.257223563,LENGTHUNIT[“metre”,1]],ENSEMBLEACCURACY[2.0]],PRIMEM[“Greenwich”,0,ANGLEUNIT[“degree”,0.0174532925199433]],CS[ellipsoidal,2],AXIS[“geodetic latitude (Lat)”,north,ORDER[1],ANGLEUNIT[“degree”,0.0174532925199433]],AXIS[“geodetic longitude (Lon)”,east,ORDER[2],ANGLEUNIT[“degree”,0.0174532925199433]],USAGE[SCOPE[“Horizontal component of 3D system.”],AREA[“World.”],BBOX[-90,-180,90,180]],ID[“EPSG”,4326]]GeoTransform :-109.07797340998921 0.0014372978798746772 0.0 41.00370749308155 0.0 -0.001437297879988364[1 values with dtype=int32]/5(6)Dimensions:time: 9band: 2y: 1400x: 2448Coordinates: (4)time(time)int322017 2018 2019 … 2023 2024 2025_zarrs :{‘description’: ‘This array was created with zarrs’, ‘repository’: ‘https://github.com/zarrs/zarrs’, ‘version’: ‘0.23.0’}array([2017, 2018, 2019, 2020, 2021, 2022, 2023, 2024, 2025], dtype=int32)band(band)StringDType()‘difference_alpha_earth_cosine_d…array(['difference_alpha_earth_cosine_distance_robust_time_zscore','loo_medoid_alpha_earth_cosine_distance_robust_time_zscore'],dtype=StringDType())y(y)float6441.0 41.0 41.0 … 36.98 36.98units :metersstandard_name :projection_y_coordinatearray([41.00227 , 40.999396, 40.996521, ..., 36.98646 , 36.983585, 36.980711],shape=(1400,))x(x)float64-109.1 -109.1 … -102.0 -102.0units :metersstandard_name :projection_x_coordinatearray([-109.076536, -109.073662, -109.070787, ..., -102.048149, -102.045275,-102.0424 ], shape=(2448,))Data variables: (2)embeddings(time, band, y, x)float32…[61689600 values with dtype=float32]spatial_ref()int32…crs_wkt :GEOGCRS[“WGS 84”,ENSEMBLE[“World Geodetic System 1984 ensemble”,MEMBER[“World Geodetic System 1984 (Transit)”],MEMBER[“World Geodetic System 1984 (G730)”],MEMBER[“World Geodetic System 1984 (G873)”],MEMBER[“World Geodetic System 1984 (G1150)”],MEMBER[“World Geodetic System 1984 (G1674)”],MEMBER[“World Geodetic System 1984 (G1762)”],MEMBER[“World Geodetic System 1984 (G2139)”],MEMBER[“World Geodetic System 1984 (G2296)”],ELLIPSOID[“WGS 84”,6378137,298.257223563,LENGTHUNIT[“metre”,1]],ENSEMBLEACCURACY[2.0]],PRIMEM[“Greenwich”,0,ANGLEUNIT[“degree”,0.0174532925199433]],CS[ellipsoidal,2],AXIS[“geodetic latitude (Lat)”,north,ORDER[1],ANGLEUNIT[“degree”,0.0174532925199433]],AXIS[“geodetic longitude (Lon)”,east,ORDER[2],ANGLEUNIT[“degree”,0.0174532925199433]],USAGE[SCOPE[“Horizontal component of 3D system.”],AREA[“World.”],BBOX[-90,-180,90,180]],ID[“EPSG”,4326]]GeoTransform :-109.07797340998921 0.0028745957597493543 0.0 41.00370749308155 0.0 -0.002874595759976728[1 values with dtype=int32]/6(6)Dimensions:time: 9band: 2y: 700x: 1224Coordinates: (4)time(time)int322017 2018 2019 … 2023 2024 2025_zarrs :{‘description’: ‘This array was created with zarrs’, ‘repository’: ‘https://github.com/zarrs/zarrs’, ‘version’: ‘0.23.0’}array([2017, 2018, 2019, 2020, 2021, 2022, 2023, 2024, 2025], dtype=int32)band(band)StringDType()‘difference_alpha_earth_cosine_d…array(['difference_alpha_earth_cosine_distance_robust_time_zscore','loo_medoid_alpha_earth_cosine_distance_robust_time_zscore'],dtype=StringDType())y(y)float6441.0 41.0 40.99 … 36.99 36.98units :metersstandard_name :projection_y_coordinatearray([41.000833, 40.995084, 40.989335, ..., 36.993646, 36.987897, 36.982148],shape=(700,))x(x)float64-109.1 -109.1 … -102.0 -102.0units :metersstandard_name :projection_x_coordinatearray([-109.075099, -109.06935 , -109.0636 , ..., -102.055336, -102.049587,-102.043838], shape=(1224,))Data variables: (2)embeddings(time, band, y, x)float32…[15422400 values with dtype=float32]spatial_ref()int32…crs_wkt :GEOGCRS[“WGS 84”,ENSEMBLE[“World Geodetic System 1984 ensemble”,MEMBER[“World Geodetic System 1984 (Transit)”],MEMBER[“World Geodetic System 1984 (G730)”],MEMBER[“World Geodetic System 1984 (G873)”],MEMBER[“World Geodetic System 1984 (G1150)”],MEMBER[“World Geodetic System 1984 (G1674)”],MEMBER[“World Geodetic System 1984 (G1762)”],MEMBER[“World Geodetic System 1984 (G2139)”],MEMBER[“World Geodetic System 1984 (G2296)”],ELLIPSOID[“WGS 84”,6378137,298.257223563,LENGTHUNIT[“metre”,1]],ENSEMBLEACCURACY[2.0]],PRIMEM[“Greenwich”,0,ANGLEUNIT[“degree”,0.0174532925199433]],CS[ellipsoidal,2],AXIS[“geodetic latitude (Lat)”,north,ORDER[1],ANGLEUNIT[“degree”,0.0174532925199433]],AXIS[“geodetic longitude (Lon)”,east,ORDER[2],ANGLEUNIT[“degree”,0.0174532925199433]],USAGE[SCOPE[“Horizontal component of 3D system.”],AREA[“World.”],BBOX[-90,-180,90,180]],ID[“EPSG”,4326]]GeoTransform :-109.07797340998921 0.005749191519498709 0.0 41.00370749308155 0.0 -0.005749191519953456[1 values with dtype=int32]/7(6)Dimensions:time: 9band: 2y: 350x: 612Coordinates: (4)time(time)int322017 2018 2019 … 2023 2024 2025_zarrs :{‘description’: ‘This array was created with zarrs’, ‘repository’: ‘https://github.com/zarrs/zarrs’, ‘version’: ‘0.23.0’}array([2017, 2018, 2019, 2020, 2021, 2022, 2023, 2024, 2025], dtype=int32)band(band)StringDType()‘difference_alpha_earth_cosine_d…array(['difference_alpha_earth_cosine_distance_robust_time_zscore','loo_medoid_alpha_earth_cosine_distance_robust_time_zscore'],dtype=StringDType())y(y)float6441.0 40.99 40.97 … 37.0 36.99units :metersstandard_name :projection_y_coordinatearray([40.997958, 40.98646 , 40.974962, ..., 37.008019, 36.996521, 36.985023],shape=(350,))x(x)float64-109.1 -109.1 … -102.1 -102.0units :metersstandard_name :projection_x_coordinatearray([-109.072224, -109.060726, -109.049227, ..., -102.069709, -102.058211,-102.046712], shape=(612,))Data variables: (2)embeddings(time, band, y, x)float32…[3855600 values with dtype=float32]spatial_ref()int32…crs_wkt :GEOGCRS[“WGS 84”,ENSEMBLE[“World Geodetic System 1984 ensemble”,MEMBER[“World Geodetic System 1984 (Transit)”],MEMBER[“World Geodetic System 1984 (G730)”],MEMBER[“World Geodetic System 1984 (G873)”],MEMBER[“World Geodetic System 1984 (G1150)”],MEMBER[“World Geodetic System 1984 (G1674)”],MEMBER[“World Geodetic System 1984 (G1762)”],MEMBER[“World Geodetic System 1984 (G2139)”],MEMBER[“World Geodetic System 1984 (G2296)”],ELLIPSOID[“WGS 84”,6378137,298.257223563,LENGTHUNIT[“metre”,1]],ENSEMBLEACCURACY[2.0]],PRIMEM[“Greenwich”,0,ANGLEUNIT[“degree”,0.0174532925199433]],CS[ellipsoidal,2],AXIS[“geodetic latitude (Lat)”,north,ORDER[1],ANGLEUNIT[“degree”,0.0174532925199433]],AXIS[“geodetic longitude (Lon)”,east,ORDER[2],ANGLEUNIT[“degree”,0.0174532925199433]],USAGE[SCOPE[“Horizontal component of 3D system.”],AREA[“World.”],BBOX[-90,-180,90,180]],ID[“EPSG”,4326]]GeoTransform :-109.07797340998921 0.011498383038997417 0.0 41.00370749308155 0.0 -0.011498383039906912[1 values with dtype=int32]/8(6)Dimensions:time: 9band: 2y: 175x: 306Coordinates: (4)time(time)int322017 2018 2019 … 2023 2024 2025_zarrs :{‘description’: ‘This array was created with zarrs’, ‘repository’: ‘https://github.com/zarrs/zarrs’, ‘version’: ‘0.23.0’}array([2017, 2018, 2019, 2020, 2021, 2022, 2023, 2024, 2025], dtype=int32)band(band)StringDType()‘difference_alpha_earth_cosine_d…array(['difference_alpha_earth_cosine_distance_robust_time_zscore','loo_medoid_alpha_earth_cosine_distance_robust_time_zscore'],dtype=StringDType())y(y)float6440.99 40.97 40.95 … 37.01 36.99units :metersstandard_name :projection_y_coordinatearray([40.992209, 40.969212, 40.946216, 40.923219, 40.900222, 40.877225,40.854229, 40.831232, 40.808235, 40.785238, 40.762241, 40.739245,40.716248, 40.693251, 40.670254, 40.647258, 40.624261, 40.601264,40.578267, 40.555271, 40.532274, 40.509277, 40.48628 , 40.463283,40.440287, 40.41729 , 40.394293, 40.371296, 40.3483 , 40.325303,40.302306, 40.279309, 40.256313, 40.233316, 40.210319, 40.187322,40.164326, 40.141329, 40.118332, 40.095335, 40.072338, 40.049342,40.026345, 40.003348, 39.980351, 39.957355, 39.934358, 39.911361,39.888364, 39.865368, 39.842371, 39.819374, 39.796377, 39.773381,39.750384, 39.727387, 39.70439 , 39.681393, 39.658397, 39.6354 ,39.612403, 39.589406, 39.56641 , 39.543413, 39.520416, 39.497419,39.474423, 39.451426, 39.428429, 39.405432, 39.382435, 39.359439,39.336442, 39.313445, 39.290448, 39.267452, 39.244455, 39.221458,39.198461, 39.175465, 39.152468, 39.129471, 39.106474, 39.083478,39.060481, 39.037484, 39.014487, 38.99149 , 38.968494, 38.945497,38.9225 , 38.899503, 38.876507, 38.85351 , 38.830513, 38.807516,38.78452 , 38.761523, 38.738526, 38.715529, 38.692533, 38.669536,38.646539, 38.623542, 38.600545, 38.577549, 38.554552, 38.531555,38.508558, 38.485562, 38.462565, 38.439568, 38.416571, 38.393575,38.370578, 38.347581, 38.324584, 38.301587, 38.278591, 38.255594,38.232597, 38.2096 , 38.186604, 38.163607, 38.14061 , 38.117613,38.094617, 38.07162 , 38.048623, 38.025626, 38.00263 , 37.979633,37.956636, 37.933639, 37.910642, 37.887646, 37.864649, 37.841652,37.818655, 37.795659, 37.772662, 37.749665, 37.726668, 37.703672,37.680675, 37.657678, 37.634681, 37.611684, 37.588688, 37.565691,37.542694, 37.519697, 37.496701, 37.473704, 37.450707, 37.42771 ,37.404714, 37.381717, 37.35872 , 37.335723, 37.312727, 37.28973 ,37.266733, 37.243736, 37.220739, 37.197743, 37.174746, 37.151749,37.128752, 37.105756, 37.082759, 37.059762, 37.036765, 37.013769,36.990772])x(x)float64-109.1 -109.0 … -102.1 -102.1units :metersstandard_name :projection_x_coordinatearray([-109.066475, -109.043478, -109.020481, ..., -102.098455, -102.075458,-102.052461], shape=(306,))Data variables: (2)embeddings(time, band, y, x)float32…[963900 values with dtype=float32]spatial_ref()int32…crs_wkt :GEOGCRS[“WGS 84”,ENSEMBLE[“World Geodetic System 1984 ensemble”,MEMBER[“World Geodetic System 1984 (Transit)”],MEMBER[“World Geodetic System 1984 (G730)”],MEMBER[“World Geodetic System 1984 (G873)”],MEMBER[“World Geodetic System 1984 (G1150)”],MEMBER[“World Geodetic System 1984 (G1674)”],MEMBER[“World Geodetic System 1984 (G1762)”],MEMBER[“World Geodetic System 1984 (G2139)”],MEMBER[“World Geodetic System 1984 (G2296)”],ELLIPSOID[“WGS 84”,6378137,298.257223563,LENGTHUNIT[“metre”,1]],ENSEMBLEACCURACY[2.0]],PRIMEM[“Greenwich”,0,ANGLEUNIT[“degree”,0.0174532925199433]],CS[ellipsoidal,2],AXIS[“geodetic latitude (Lat)”,north,ORDER[1],ANGLEUNIT[“degree”,0.0174532925199433]],AXIS[“geodetic longitude (Lon)”,east,ORDER[2],ANGLEUNIT[“degree”,0.0174532925199433]],USAGE[SCOPE[“Horizontal component of 3D system.”],AREA[“World.”],BBOX[-90,-180,90,180]],ID[“EPSG”,4326]]GeoTransform :-109.07797340998921 0.022996766077994835 0.0 41.00370749308155 0.0 -0.022996766079813824[1 values with dtype=int32]/9(6)Dimensions:time: 9band: 2y: 88x: 153Coordinates: (4)time(time)int322017 2018 2019 … 2023 2024 2025_zarrs :{‘description’: ‘This array was created with zarrs’, ‘repository’: ‘https://github.com/zarrs/zarrs’, ‘version’: ‘0.23.0’}array([2017, 2018, 2019, 2020, 2021, 2022, 2023, 2024, 2025], dtype=int32)band(band)StringDType()‘difference_alpha_earth_cosine_d…array(['difference_alpha_earth_cosine_distance_robust_time_zscore','loo_medoid_alpha_earth_cosine_distance_robust_time_zscore'],dtype=StringDType())y(y)float6440.98 40.93 40.89 … 37.03 36.98units :metersstandard_name :projection_y_coordinatearray([40.980711, 40.934717, 40.888724, 40.84273 , 40.796737, 40.750743,40.70475 , 40.658756, 40.612762, 40.566769, 40.520775, 40.474782,40.428788, 40.382795, 40.336801, 40.290808, 40.244814, 40.198821,40.152827, 40.106834, 40.06084 , 40.014847, 39.968853, 39.922859,39.876866, 39.830872, 39.784879, 39.738885, 39.692892, 39.646898,39.600905, 39.554911, 39.508918, 39.462924, 39.416931, 39.370937,39.324944, 39.27895 , 39.232957, 39.186963, 39.140969, 39.094976,39.048982, 39.002989, 38.956995, 38.911002, 38.865008, 38.819015,38.773021, 38.727028, 38.681034, 38.635041, 38.589047, 38.543054,38.49706 , 38.451066, 38.405073, 38.359079, 38.313086, 38.267092,38.221099, 38.175105, 38.129112, 38.083118, 38.037125, 37.991131,37.945138, 37.899144, 37.853151, 37.807157, 37.761163, 37.71517 ,37.669176, 37.623183, 37.577189, 37.531196, 37.485202, 37.439209,37.393215, 37.347222, 37.301228, 37.255235, 37.209241, 37.163248,37.117254, 37.07126 , 37.025267, 36.979273])x(x)float64-109.1 -109.0 … -102.1 -102.1units :metersstandard_name :projection_x_coordinatearray([-109.054977, -109.008983, -108.96299 , -108.916996, -108.871003,-108.825009, -108.779015, -108.733022, -108.687028, -108.641035,-108.595041, -108.549048, -108.503054, -108.457061, -108.411067,-108.365074, -108.31908 , -108.273087, -108.227093, -108.1811 ,-108.135106, -108.089112, -108.043119, -107.997125, -107.951132,-107.905138, -107.859145, -107.813151, -107.767158, -107.721164,-107.675171, -107.629177, -107.583184, -107.53719 , -107.491197,-107.445203, -107.399209, -107.353216, -107.307222, -107.261229,-107.215235, -107.169242, -107.123248, -107.077255, -107.031261,-106.985268, -106.939274, -106.893281, -106.847287, -106.801294,-106.7553 , -106.709307, -106.663313, -106.617319, -106.571326,-106.525332, -106.479339, -106.433345, -106.387352, -106.341358,-106.295365, -106.249371, -106.203378, -106.157384, -106.111391,-106.065397, -106.019404, -105.97341 , -105.927416, -105.881423,-105.835429, -105.789436, -105.743442, -105.697449, -105.651455,-105.605462, -105.559468, -105.513475, -105.467481, -105.421488,-105.375494, -105.329501, -105.283507, -105.237513, -105.19152 ,-105.145526, -105.099533, -105.053539, -105.007546, -104.961552,-104.915559, -104.869565, -104.823572, -104.777578, -104.731585,-104.685591, -104.639598, -104.593604, -104.54761 , -104.501617,-104.455623, -104.40963 , -104.363636, -104.317643, -104.271649,-104.225656, -104.179662, -104.133669, -104.087675, -104.041682,-103.995688, -103.949695, -103.903701, -103.857708, -103.811714,-103.76572 , -103.719727, -103.673733, -103.62774 , -103.581746,-103.535753, -103.489759, -103.443766, -103.397772, -103.351779,-103.305785, -103.259792, -103.213798, -103.167805, -103.121811,-103.075817, -103.029824, -102.98383 , -102.937837, -102.891843,-102.84585 , -102.799856, -102.753863, -102.707869, -102.661876,-102.615882, -102.569889, -102.523895, -102.477902, -102.431908,-102.385914, -102.339921, -102.293927, -102.247934, -102.20194 ,-102.155947, -102.109953, -102.06396 ])Data variables: (2)embeddings(time, band, y, x)float32…[242352 values with dtype=float32]spatial_ref()int32…crs_wkt :GEOGCRS[“WGS 84”,ENSEMBLE[“World Geodetic System 1984 ensemble”,MEMBER[“World Geodetic System 1984 (Transit)”],MEMBER[“World Geodetic System 1984 (G730)”],MEMBER[“World Geodetic System 1984 (G873)”],MEMBER[“World Geodetic System 1984 (G1150)”],MEMBER[“World Geodetic System 1984 (G1674)”],MEMBER[“World Geodetic System 1984 (G1762)”],MEMBER[“World Geodetic System 1984 (G2139)”],MEMBER[“World Geodetic System 1984 (G2296)”],ELLIPSOID[“WGS 84”,6378137,298.257223563,LENGTHUNIT[“metre”,1]],ENSEMBLEACCURACY[2.0]],PRIMEM[“Greenwich”,0,ANGLEUNIT[“degree”,0.0174532925199433]],CS[ellipsoidal,2],AXIS[“geodetic latitude (Lat)”,north,ORDER[1],ANGLEUNIT[“degree”,0.0174532925199433]],AXIS[“geodetic longitude (Lon)”,east,ORDER[2],ANGLEUNIT[“degree”,0.0174532925199433]],USAGE[SCOPE[“Horizontal component of 3D system.”],AREA[“World.”],BBOX[-90,-180,90,180]],ID[“EPSG”,4326]]GeoTransform :-109.07797340998921 0.04599353215598967 0.0 41.00370749308155 0.0 -0.04599353215962765[1 values with dtype=int32]Attributes: (10)zarr_conventions :[{‘uuid’: ‘d35379db-88df-4056-af3a-620245f8e347’, ‘name’: ‘multiscales’, ‘schema_url’: ‘https://raw.githubusercontent.com/zarr-conventions/multiscales/refs/tags/v1/schema.json’}, {‘uuid’: ‘f17cb550-5864-4468-aeb7-f3180cfb622f’, ‘name’: ‘proj:’, ‘schema_url’: ‘https://raw.githubusercontent.com/zarr-experimental/geo-proj/refs/tags/v1/schema.json’}, {‘uuid’: ‘689b58e2-cf7b-45e0-9fff-9cfc0883d6b4’, ‘name’: ‘spatial:’, ‘schema_url’: ‘https://raw.githubusercontent.com/zarr-conventions/spatial/refs/tags/v1/schema.json’}]multiscales :{‘layout’: [{‘asset’: ‘0’, ‘transform’: {‘scale’: [1.0, 1.0], ‘translation’: [0.0, 0.0]}, ‘spatial:shape’: [44800, 78336], ‘spatial:transform’: [8.983111749216732e-05, 0.0, -109.07797340998921, 0.0, -8.983111749927275e-05, 41.00370749308155]}, {‘asset’: ‘1’, ‘transform’: {‘scale’: [2.0, 2.0], ‘translation’: [0.0, 0.0]}, ‘derived_from’: ‘0’, ‘spatial:shape’: [22400, 39168], ‘spatial:transform’: [0.00017966223498433465, 0.0, -109.07797340998921, 0.0, -0.0001796622349985455, 41.00370749308155]}, {‘asset’: ‘2’, ‘transform’: {‘scale’: [4.0, 4.0], ‘translation’: [0.0, 0.0]}, ‘derived_from’: ‘1’, ‘spatial:shape’: [11200, 19584], ‘spatial:transform’: [0.0003593244699686693, 0.0, -109.07797340998921, 0.0, -0.000359324469997091, 41.00370749308155]}, {‘asset’: ‘3’, ‘transform’: {‘scale’: [8.0, 8.0], ‘translation’: [0.0, 0.0]}, ‘derived_from’: ‘2’, ‘spatial:shape’: [5600, 9792], ‘spatial:transform’: [0.0007186489399373386, 0.0, -109.07797340998921, 0.0, -0.000718648939994182, 41.00370749308155]}, {‘asset’: ‘4’, ‘transform’: {‘scale’: [16.0, 16.0], ‘translation’: [0.0, 0.0]}, ‘derived_from’: ‘3’, ‘spatial:shape’: [2800, 4896], ‘spatial:transform’: [0.0014372978798746772, 0.0, -109.07797340998921, 0.0, -0.001437297879988364, 41.00370749308155]}, {‘asset’: ‘5’, ‘transform’: {‘scale’: [32.0, 32.0], ‘translation’: [0.0, 0.0]}, ‘derived_from’: ‘4’, ‘spatial:shape’: [1400, 2448], ‘spatial:transform’: [0.0028745957597493543, 0.0, -109.07797340998921, 0.0, -0.002874595759976728, 41.00370749308155]}, {‘asset’: ‘6’, ‘transform’: {‘scale’: [64.0, 64.0], ‘translation’: [0.0, 0.0]}, ‘derived_from’: ‘5’, ‘spatial:shape’: [700, 1224], ‘spatial:transform’: [0.005749191519498709, 0.0, -109.07797340998921, 0.0, -0.005749191519953456, 41.00370749308155]}, {‘asset’: ‘7’, ‘transform’: {‘scale’: [128.0, 128.0], ‘translation’: [0.0, 0.0]}, ‘derived_from’: ‘6’, ‘spatial:shape’: [350, 612], ‘spatial:transform’: [0.011498383038997417, 0.0, -109.07797340998921, 0.0, -0.011498383039906912, 41.00370749308155]}, {‘asset’: ‘8’, ‘transform’: {‘scale’: [256.0, 256.0], ‘translation’: [0.0, 0.0]}, ‘derived_from’: ‘7’, ‘spatial:shape’: [175, 306], ‘spatial:transform’: [0.022996766077994835, 0.0, -109.07797340998921, 0.0, -0.022996766079813824, 41.00370749308155]}, {‘asset’: ‘9’, ‘transform’: {‘scale’: [512.0, 512.0], ‘translation’: [0.0, 0.0]}, ‘derived_from’: ‘8’, ‘spatial:shape’: [88, 153], ‘spatial:transform’: [0.04599353215598967, 0.0, -109.07797340998921, 0.0, -0.04599353215962765, 41.00370749308155]}], ‘resampling_method’: ‘average’}proj:code :EPSG:4326proj:wkt2 :GEOGCRS[“WGS 84”,ENSEMBLE[“World Geodetic System 1984 ensemble”,MEMBER[“World Geodetic System 1984 (Transit)”],MEMBER[“World Geodetic System 1984 (G730)”],MEMBER[“World Geodetic System 1984 (G873)”],MEMBER[“World Geodetic System 1984 (G1150)”],MEMBER[“World Geodetic System 1984 (G1674)”],MEMBER[“World Geodetic System 1984 (G1762)”],MEMBER[“World Geodetic System 1984 (G2139)”],MEMBER[“World Geodetic System 1984 (G2296)”],ELLIPSOID[“WGS 84”,6378137,298.257223563,LENGTHUNIT[“metre”,1]],ENSEMBLEACCURACY[2.0]],PRIMEM[“Greenwich”,0,ANGLEUNIT[“degree”,0.0174532925199433]],CS[ellipsoidal,2],AXIS[“geodetic latitude (Lat)”,north,ORDER[1],ANGLEUNIT[“degree”,0.0174532925199433]],AXIS[“geodetic longitude (Lon)”,east,ORDER[2],ANGLEUNIT[“degree”,0.0174532925199433]],USAGE[SCOPE[“Horizontal component of 3D system.”],AREA[“World.”],BBOX[-90,-180,90,180]],ID[“EPSG”,4326]]spatial:dimensions :[‘y’, ‘x’]spatial:registration :pixelspatial:transform_type :affinespatial:shape :[44800, 78336]spatial:transform :[8.983111749216732e-05, 0.0, -109.07797340998921, 0.0, -8.983111749927275e-05, 41.00370749308155]spatial:bbox :[-109.07797340998921, 36.97927342911413, -102.0409629901228, 41.00370749308155] 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 START BUILDING Key takeawaysRaw 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 Posted on May 6, 2026October 4, 2026 by Ben Pruden 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'...xarray.DatasetDimensions:time: 9band: 64y: 1859584x: 4009984Coordinates: (4)time(time)int322017 2018 2019 … 2023 2024 2025_zarrs :{‘description’: ‘This array was created with zarrs’, ‘repository’: ‘https://github.com/zarrs/zarrs’, ‘version’: ‘0.23.0’}array([2017, 2018, 2019, 2020, 2021, 2022, 2023, 2024, 2025], dtype=int32)band(band)object‘A00’ ‘A01’ ‘A02’ … ‘A62’ ‘A63’_zarrs :{‘description’: ‘This array was created with zarrs’, ‘repository’: ‘https://github.com/zarrs/zarrs’, ‘version’: ‘0.23.0’}array(['A00', 'A01', 'A02', 'A03', 'A04', 'A05', 'A06', 'A07', 'A08', 'A09', 'A10', 'A11', 'A12', 'A13', 'A14', 'A15', 'A16', 'A17', 'A18', 'A19', 'A20', 'A21', 'A22', 'A23', 'A24', 'A25', 'A26', 'A27', 'A28', 'A29', 'A30', 'A31', 'A32', 'A33', 'A34', 'A35', 'A36', 'A37', 'A38', 'A39', 'A40', 'A41', 'A42', 'A43', 'A44', 'A45', 'A46', 'A47', 'A48', 'A49', 'A50', 'A51', 'A52', 'A53', 'A54', 'A55', 'A56', 'A57', 'A58', 'A59', 'A60', 'A61', 'A62', 'A63'], dtype=object)y(y)float6483.69 83.69 83.69 … -83.36 -83.36_zarrs :{‘description’: ‘This array was created with zarrs’, ‘repository’: ‘https://github.com/zarrs/zarrs’, ‘version’: ‘0.23.0’}array([ 83.68566 , 83.685571, 83.685481, ..., -83.362579, -83.362669, -83.362759], shape=(1859584,))x(x)float64-180.0 -180.0 … 180.2 180.2_zarrs :{‘description’: ‘This array was created with zarrs’, ‘repository’: ‘https://github.com/zarrs/zarrs’, ‘version’: ‘0.23.0’}array([-179.999955, -179.999865, -179.999775, ..., 180.221119, 180.221209, 180.221299], shape=(4009984,))Data variables: (1)embeddings(time, band, y, x)int8dask.array<chunksize=(1, 64, 256, 256), meta=np.ndarray>_zarrs :{‘description’: ‘This array was created with zarrs’, ‘repository’: ‘https://github.com/zarrs/zarrs’, ‘version’: ‘0.23.0’} Array Chunk Bytes 3.81 PiB 4.00 MiB Shape (9, 64, 1859584, 4009984) (1, 64, 256, 256) Dask graph 1024049664 chunks in 2 graph layers Data type int8 numpy.ndarray 9 1 4009984 1859584 64 Attributes: (15)proj:code :EPSG:4326spatial:dimensions :[‘y’, ‘x’]spatial:transform :[8.983111749910169e-05, 0.0, -180.0, 0.0, -8.983111749910169e-05, 83.68570533713473]spatial:transform_type :affinespatial:bbox :[-180.0, -83.36280346631479, 180.2213438735178, 83.68570533713473]spatial:shape :[1859584, 4009984]spatial:registration :pixelgeoemb:type :pixelgeoemb:dimensions :64geoemb:model :https://developers.google.com/earth-engine/datasets/catalog/GOOGLE_SATELLITE_EMBEDDING_V1_ANNUALgeoemb:source_data :https://source.coop/tge-labs/aef/v1/annual/geoemb:data_type :int8geoemb:gsd :8.983111749910169e-05geoemb:quantization :{‘method’: ‘signed_square’, ‘original_dtype’: ‘float32’, ‘quantized_dtype’: ‘int8’, ‘formula’: ‘(x / 127.5) ** 2 * sign(x)’, ‘valid_range’: [-127, 127], ‘nodata’: -128}zarr_conventions :[{‘uuid’: ‘f17cb550-5864-4468-aeb7-f3180cfb622f’, ‘name’: ‘proj:’, ‘description’: ‘Coordinate reference system information for geospatial data’, ‘spec_url’: ‘https://github.com/zarr-experimental/geo-proj/blob/v1/README.md’, ‘schema_url’: ‘https://raw.githubusercontent.com/zarr-experimental/geo-proj/refs/tags/v1/schema.json’}, {‘uuid’: ‘689b58e2-cf7b-45e0-9fff-9cfc0883d6b4’, ‘name’: ‘spatial:’, ‘description’: ‘Spatial coordinate information’, ‘spec_url’: ‘https://github.com/zarr-conventions/spatial/blob/v1/README.md’, ‘schema_url’: ‘https://raw.githubusercontent.com/zarr-conventions/spatial/refs/tags/v1/schema.json’}, {‘uuid’: ’61c12cc5-0e28-4056-999a-480cf3fb7e4c’, ‘name’: ‘geoemb:’, ‘description’: ‘Geo-embeddings metadata for machine learning embeddings stored in Zarr’, ‘spec_url’: ‘https://github.com/geo-embeddings/embeddings-zarr-convention/tree/add-convention’}] 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 Get Started Key takeawaysMean 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 Posted on May 4, 2026October 3, 2026 by Ryan Avery 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. 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 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 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 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. 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 Outputbuild_gti_mosaic11m 47sNAIP Zarr mosaic133.08 GB (123.94 GiB)~114.15 km × 66.81 kmpredict_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. See it in action Join us live 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 correctly detects tractors in a harvested field. In an agricultural context, SAM 3 finds some tractors but also misses some. SAM 3 correctly identifies shipping containers in a storage yard. 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 START BUILDING Key takeawaysRasterFlow 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 Agricultural Fields Change in AlphaEarth Foundations Posted on April 7, 2026October 4, 2026 by Ben Pruden Author: Len Strnad AlphaEarth Foundations is a geospatial AI model from Google DeepMind. It compresses a year of satellite observations into 64-dimensional embedding vectors for every 10-meter pixel on Earth’s land surfaces and shallow coastal waters annually. In this notebook, we use AEF to examine how agricultural fields evolve across several regions and ask a practical question: what kinds of field-level change become visible when we inspect the embeddings directly rather than relying only on imagery? We begin by defining a few agricultural areas of interest and building Zarr mosaics over each one from COGs available in Source Cooperative. From there, we compare several views of temporal change: the first three embedding bands rendered as RGB, a Principal Component Analysis (PCA) projection that makes broader structure easier to interpret, and an experimental distance-over-time workflow that highlights where embedding values remain stable or shift over time. The goal is not to claim a single definitive interpretation of AEF. Instead, this notebook shows a practical workflow for exploring seasonal cycles, repeated interventions, and other field-scale dynamics in embedding space. RasterFlow Tools Used RasterFlow is Wherobots’ serverless inference engine for Earth Observation data. It builds inference-ready mosaics from satellite imagery and runs distributed workflows at scale. This notebook uses three RasterFlow capabilities: build_gti_mosaic for constructing Zarr mosaics from AlphaEarth Foundations data an experimental PCA workflow for dimensionality reduction and an experimental distance-over-time workflow for measuring embedding change between timesteps. Selecting Agricultural AOIs Across Iowa, California, and Japan We start with three small AOIs that contain agricultural fields in different regions. This gives us a compact but varied set of examples for exploring how AlphaEarth Foundations embeddings change over time. import geopandas as gpd from shapely.geometry import box from folium import Map iowa1 = [-94.732361, 41.911475, -94.237289, 42.112996] california1 = [-120.11095, 36.63082, -120.018082, 36.683839] japan1 = [141.018019, 39.226668, 141.067801, 39.249803] aois = [iowa1, california1, japan1] gdf = gpd.GeoDataFrame({"geometry": [box(*aoi) for aoi in aois]}, crs="EPSG:4326") # start on the first AOI m = Map(location=gdf.iloc[0].geometry.centroid.coords[0][::-1], zoom_start=8) gdf.explore(height="120%", width="120%", m=m) Make this Notebook Trusted to load map: File -> Trust Notebook # store this out to parameterize which Zarr mosaics we want to build for AEF gdf.to_parquet("s3://union-sandbox-unionai-wherobots/aois/field_change_aef.parquet") AlphaEarth Foundations Zarr Mosaics by AOI The next step is to create an AlphaEarth Foundations embeddings Zarr mosaic for each AOI. This gives us a time-indexed raster store that we can query efficiently for visualization and downstream analysis. The output here is from the build_gti_mosaic RasterFlow workflow. import geopandas as gpd mosaic_index = gpd.read_parquet( "s3://union-sandbox-unionai-wherobots/ha/wherobots/wherobots-mosaics/development/rnmkrr4sbxsdf24t288p/d8cq2y2k3b1r4p9gr0k5n1ldh/1/7q/fdjxpi5q/61a6e8f637cce7ddf177ff7a6637aceb/" ) mosaic_index geometry location 0 POLYGON ((-94.23729 41.91148, -94.23729 42.113… s3://sandbox-wherobots-mosaics-tmp/mosaics/rnm… 1 POLYGON ((-120.01808 36.63082, -120.01808 36.6… s3://sandbox-wherobots-mosaics-tmp/mosaics/rnm… 2 POLYGON ((141.0678 39.22667, 141.0678 39.2498,… s3://sandbox-wherobots-mosaics-tmp/mosaics/rnm… The result is one Zarr mosaic per AOI, which we can load independently for inspection and comparison. Inspect an Example AEF Zarr Mosaic import xarray as xr ds = xr.open_zarr(mosaic_index.iloc[0]["location"]) ds <xarray.Dataset> Size: 38GB Dimensions: (time: 9, band: 64, y: 3020, x: 5512) Coordinates: * time (time) datetime64[ns] 72B 2017-01-01 2018-01-01 ... 2025-01-01 * band (band) object 512B 'band_0' 'band_1' ... 'band_62' 'band_63' * y (y) float64 24kB 5.178e+06 5.178e+06 ... 5.148e+06 5.148e+06 * x (x) float64 44kB -1.055e+07 -1.055e+07 ... -1.049e+07 spatial_ref int64 8B ... Data variables: variables (time, band, y, x) float32 38GB dask.array<chunksize=(1, 1, 1024, 1024), meta=np.ndarray>xarray.DatasetDimensions:time: 9band: 64y: 3020x: 5512Coordinates: (5)time(time)datetime64[ns]2017-01-01 … 2025-01-01array(['2017-01-01T00:00:00.000000000', '2018-01-01T00:00:00.000000000', '2019-01-01T00:00:00.000000000', '2020-01-01T00:00:00.000000000', '2021-01-01T00:00:00.000000000', '2022-01-01T00:00:00.000000000', '2023-01-01T00:00:00.000000000', '2024-01-01T00:00:00.000000000', '2025-01-01T00:00:00.000000000'], dtype='datetime64[ns]')band(band)object‘band_0’ ‘band_1’ … ‘band_63’array(['band_0', 'band_1', 'band_2', 'band_3', 'band_4', 'band_5', 'band_6', 'band_7', 'band_8', 'band_9', 'band_10', 'band_11', 'band_12', 'band_13', 'band_14', 'band_15', 'band_16', 'band_17', 'band_18', 'band_19', 'band_20', 'band_21', 'band_22', 'band_23', 'band_24', 'band_25', 'band_26', 'band_27', 'band_28', 'band_29', 'band_30', 'band_31', 'band_32', 'band_33', 'band_34', 'band_35', 'band_36', 'band_37', 'band_38', 'band_39', 'band_40', 'band_41', 'band_42', 'band_43', 'band_44', 'band_45', 'band_46', 'band_47', 'band_48', 'band_49', 'band_50', 'band_51', 'band_52', 'band_53', 'band_54', 'band_55', 'band_56', 'band_57', 'band_58', 'band_59', 'band_60', 'band_61', 'band_62', 'band_63'], dtype=object)y(y)float645.178e+06 5.178e+06 … 5.148e+06array([5177915.753919, 5177905.753919, 5177895.753919, ..., 5147745.753919, 5147735.753919, 5147725.753919], shape=(3020,))x(x)float64-1.055e+07 … -1.049e+07array([-10545553.188165, -10545543.188165, -10545533.188165, ..., -10490463.188165, -10490453.188165, -10490443.188165], shape=(5512,))spatial_ref()int64…crs_wkt :PROJCS[“WGS 84 / Pseudo-Mercator”,GEOGCS[“WGS 84”,DATUM[“WGS_1984”,SPHEROID[“WGS 84”,6378137,298.257223563,AUTHORITY[“EPSG”,”7030″]],AUTHORITY[“EPSG”,”6326″]],PRIMEM[“Greenwich”,0,AUTHORITY[“EPSG”,”8901″]],UNIT[“degree”,0.0174532925199433,AUTHORITY[“EPSG”,”9122″]],AUTHORITY[“EPSG”,”4326″]],PROJECTION[“Mercator_1SP”],PARAMETER[“central_meridian”,0],PARAMETER[“scale_factor”,1],PARAMETER[“false_easting”,0],PARAMETER[“false_northing”,0],UNIT[“metre”,1,AUTHORITY[“EPSG”,”9001″]],AXIS[“Easting”,EAST],AXIS[“Northing”,NORTH],EXTENSION[“PROJ4″,”+proj=merc +a=6378137 +b=6378137 +lat_ts=0 +lon_0=0 +x_0=0 +y_0=0 +k=1 +units=m +nadgrids=@null +wktext +no_defs”],AUTHORITY[“EPSG”,”3857″]]spatial_ref :PROJCS[“WGS 84 / Pseudo-Mercator”,GEOGCS[“WGS 84”,DATUM[“WGS_1984”,SPHEROID[“WGS 84”,6378137,298.257223563,AUTHORITY[“EPSG”,”7030″]],AUTHORITY[“EPSG”,”6326″]],PRIMEM[“Greenwich”,0,AUTHORITY[“EPSG”,”8901″]],UNIT[“degree”,0.0174532925199433,AUTHORITY[“EPSG”,”9122″]],AUTHORITY[“EPSG”,”4326″]],PROJECTION[“Mercator_1SP”],PARAMETER[“central_meridian”,0],PARAMETER[“scale_factor”,1],PARAMETER[“false_easting”,0],PARAMETER[“false_northing”,0],UNIT[“metre”,1,AUTHORITY[“EPSG”,”9001″]],AXIS[“Easting”,EAST],AXIS[“Northing”,NORTH],EXTENSION[“PROJ4″,”+proj=merc +a=6378137 +b=6378137 +lat_ts=0 +lon_0=0 +x_0=0 +y_0=0 +k=1 +units=m +nadgrids=@null +wktext +no_defs”],AUTHORITY[“EPSG”,”3857″]]GeoTransform :-10545558.188164568 10.0 0.0 5177920.753919057 0.0 -10.0[1 values with dtype=int64]Data variables: (1)variables(time, band, y, x)float32dask.array<chunksize=(1, 1, 1024, 1024), meta=np.ndarray> Array Chunk Bytes 35.72 GiB 4.00 MiB Shape (9, 64, 3020, 5512) (1, 1, 1024, 1024) Dask graph 10368 chunks in 2 graph layers Data type float32 numpy.ndarray 9 1 5512 3020 64 # pull this into memory once to reuse subset = ds["variables"][:, :3, :, :].compute() import imageio.v3 as iio import numpy as np def to_uint8_robust(array: xr.DataArray, low: xr.DataArray, high: xr.DataArray) -> np.ndarray: arr = array.transpose("y", "x", ...).astype("float32") scale = np.where((high - low) == 0, 1.0, (high - low)) arr = (arr - low) / scale arr = np.clip(arr, 0, 1) return (arr * 255).astype(np.uint8) def robust_params(da: xr.DataArray) -> tuple[xr.DataArray, xr.DataArray]: q_low, q_high = 1, 99 lo = da.quantile(q_low / 100, dim=("time", "y", "x")) hi = da.quantile(q_high / 100, dim=("time", "y", "x")) return lo, hi low, high = robust_params(subset) frames = [to_uint8_robust(subset.sel(time=t), low, high) for t in subset.time] iio.imwrite("assets/rgb_animation.gif", frames, duration=1000, loop=0) To establish a baseline view, we render the first three embedding bands as an RGB animation over time. The color mapping is not directly semantic, but it provides a quick way to spot recurring seasonal patterns and abrupt changes. Using PCA to Identify Seasonal Patterns in Embeddings A more interpretable view comes from projecting the 64-dimensional embeddings into their first three principal components. PCA concentrates the dominant variance into three channels, which often makes field-level change easier to see. # training sample for PCA using just the first year and a spatial slice sample = ds["variables"][:, :, :1024, :1024].compute() sample = sample.transpose("band","time","y","x").data.reshape(64, -1) sample = sample[:, ~np.isnan(sample).any(axis=0)] from sklearn.decomposition import PCA model = PCA(n_components=3) model.fit(sample.T) PCA(n_components=3)In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook. On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.PCA?Documentation for PCAiFitted Parameters n_components n_components: int, float or ‘mle’, default=None Number of components to keep. if n_components is not set all components are kept:: n_components == min(n_samples, n_features) If “n_components == ‘mle’“ and “svd_solver == ‘full’“, Minka’s MLE is used to guess the dimension. Use of “n_components == ‘mle’“ will interpret “svd_solver == ‘auto’“ as “svd_solver == ‘full’“. If “0 < n_components < 1“ and “svd_solver == ‘full’“, select the number of components such that the amount of variance that needs to be explained is greater than the percentage specified by n_components. If “svd_solver == ‘arpack’“, the number of components must be strictly less than the minimum of n_features and n_samples. Hence, the None case results in:: n_components == min(n_samples, n_features) – 1 3 copy copy: bool, default=True If False, data passed to fit are overwritten and running fit(X).transform(X) will not yield the expected results, use fit_transform(X) instead. True whiten whiten: bool, default=False When True (False by default) the `components_` vectors are multiplied by the square root of n_samples and then divided by the singular values to ensure uncorrelated outputs with unit component-wise variances. Whitening will remove some information from the transformed signal (the relative variance scales of the components) but can sometime improve the predictive accuracy of the downstream estimators by making their data respect some hard-wired assumptions. False svd_solver svd_solver: {‘auto’, ‘full’, ‘covariance_eigh’, ‘arpack’, ‘randomized’}, default=’auto’ “auto” : The solver is selected by a default ‘auto’ policy is based on `X.shape` and `n_components`: if the input data has fewer than 1000 features and more than 10 times as many samples, then the “covariance_eigh” solver is used. Otherwise, if the input data is larger than 500×500 and the number of components to extract is lower than 80% of the smallest dimension of the data, then the more efficient “randomized” method is selected. Otherwise the exact “full” SVD is computed and optionally truncated afterwards. “full” : Run exact full SVD calling the standard LAPACK solver via `scipy.linalg.svd` and select the components by postprocessing “covariance_eigh” : Precompute the covariance matrix (on centered data), run a classical eigenvalue decomposition on the covariance matrix typically using LAPACK and select the components by postprocessing. This solver is very efficient for n_samples >> n_features and small n_features. It is, however, not tractable otherwise for large n_features (large memory footprint required to materialize the covariance matrix). Also note that compared to the “full” solver, this solver effectively doubles the condition number and is therefore less numerical stable (e.g. on input data with a large range of singular values). “arpack” : Run SVD truncated to `n_components` calling ARPACK solver via `scipy.sparse.linalg.svds`. It requires strictly `0 < n_components < min(X.shape)` “randomized” : Run randomized SVD by the method of Halko et al. .. versionadded:: 0.18.0 .. versionchanged:: 1.5 Added the ‘covariance_eigh’ solver. ‘auto’ tol tol: float, default=0.0 Tolerance for singular values computed by svd_solver == ‘arpack’. Must be of range [0.0, infinity). .. versionadded:: 0.18.0 0.0 iterated_power iterated_power: int or ‘auto’, default=’auto’ Number of iterations for the power method computed by svd_solver == ‘randomized’. Must be of range [0, infinity). .. versionadded:: 0.18.0 ‘auto’ n_oversamples n_oversamples: int, default=10 This parameter is only relevant when `svd_solver=”randomized”`. It corresponds to the additional number of random vectors to sample the range of `X` so as to ensure proper conditioning. See :func:`~sklearn.utils.extmath.randomized_svd` for more details. .. versionadded:: 1.1 10 power_iteration_normalizer power_iteration_normalizer: {‘auto’, ‘QR’, ‘LU’, ‘none’}, default=’auto’ Power iteration normalizer for randomized SVD solver. Not used by ARPACK. See :func:`~sklearn.utils.extmath.randomized_svd` for more details. .. versionadded:: 1.1 ‘auto’ random_state random_state: int, RandomState instance or None, default=None Used when the ‘arpack’ or ‘randomized’ solvers are used. Pass an int for reproducible results across multiple function calls. See :term:`Glossary `. .. versionadded:: 0.18.0 None To keep the notebook lightweight, we pull all time steps and bands for a small spatial subset rather than the full mosaic. AlphaEarth Foundations embeddings contain 64 bands, so this smaller window reduces data transfer while still preserving the temporal behavior we want to inspect. The same workflow can be scaled up for larger analyses. # can take 30+ seconds subset = ds["variables"][:, :, :1024, :2048].compute() # Transform with PCA flattened = subset.transpose("band", "time", "y", "x").data.reshape(64, -1).T transformed = model.transform(flattened) img = transformed.T.reshape(3, subset.shape[0], subset.shape[2], subset.shape[3]).transpose( 1, 0, 2, 3 ) # Use the existing xarray data structure to build a nice home for PCA data. subset_copy = subset.copy() array = subset_copy.isel(band=slice(0, 3)) # insert the transformed PCA data array.data = img array["band"] = ["pca1", "pca2", "pca3"] # we chunk only to get a better view of the dataset array.to_dataset(name='variables') <xarray.Dataset> Size: 227MB Dimensions: (time: 9, x: 2048, y: 1024, band: 3) Coordinates: * time (time) datetime64[ns] 72B 2017-01-01 2018-01-01 ... 2025-01-01 * x (x) float64 16kB -1.055e+07 -1.055e+07 ... -1.053e+07 * y (y) float64 8kB 5.178e+06 5.178e+06 ... 5.168e+06 5.168e+06 * band (band) <U4 48B 'pca1' 'pca2' 'pca3' spatial_ref int64 8B 0 Data variables: variables (time, band, y, x) float32 226MB 105.8 106.5 ... -27.13 -4.077xarray.DatasetDimensions:time: 9x: 2048y: 1024band: 3Coordinates: (5)time(time)datetime64[ns]2017-01-01 ... 2025-01-01array(['2017-01-01T00:00:00.000000000', '2018-01-01T00:00:00.000000000', '2019-01-01T00:00:00.000000000', '2020-01-01T00:00:00.000000000', '2021-01-01T00:00:00.000000000', '2022-01-01T00:00:00.000000000', '2023-01-01T00:00:00.000000000', '2024-01-01T00:00:00.000000000', '2025-01-01T00:00:00.000000000'], dtype='datetime64[ns]')x(x)float64-1.055e+07 ... -1.053e+07array([-10545553.188165, -10545543.188165, -10545533.188165, ..., -10525103.188165, -10525093.188165, -10525083.188165], shape=(2048,))y(y)float645.178e+06 5.178e+06 ... 5.168e+06array([5177915.753919, 5177905.753919, 5177895.753919, ..., 5167705.753919, 5167695.753919, 5167685.753919], shape=(1024,))band(band)<U4'pca1' 'pca2' 'pca3'array(['pca1', 'pca2', 'pca3'], dtype='<U4')spatial_ref()int640crs_wkt :PROJCS["WGS 84 / Pseudo-Mercator",GEOGCS["WGS 84",DATUM["WGS_1984",SPHEROID["WGS 84",6378137,298.257223563,AUTHORITY["EPSG","7030"]],AUTHORITY["EPSG","6326"]],PRIMEM["Greenwich",0,AUTHORITY["EPSG","8901"]],UNIT["degree",0.0174532925199433,AUTHORITY["EPSG","9122"]],AUTHORITY["EPSG","4326"]],PROJECTION["Mercator_1SP"],PARAMETER["central_meridian",0],PARAMETER["scale_factor",1],PARAMETER["false_easting",0],PARAMETER["false_northing",0],UNIT["metre",1,AUTHORITY["EPSG","9001"]],AXIS["Easting",EAST],AXIS["Northing",NORTH],EXTENSION["PROJ4","+proj=merc +a=6378137 +b=6378137 +lat_ts=0 +lon_0=0 +x_0=0 +y_0=0 +k=1 +units=m +nadgrids=@null +wktext +no_defs"],AUTHORITY["EPSG","3857"]]spatial_ref :PROJCS["WGS 84 / Pseudo-Mercator",GEOGCS["WGS 84",DATUM["WGS_1984",SPHEROID["WGS 84",6378137,298.257223563,AUTHORITY["EPSG","7030"]],AUTHORITY["EPSG","6326"]],PRIMEM["Greenwich",0,AUTHORITY["EPSG","8901"]],UNIT["degree",0.0174532925199433,AUTHORITY["EPSG","9122"]],AUTHORITY["EPSG","4326"]],PROJECTION["Mercator_1SP"],PARAMETER["central_meridian",0],PARAMETER["scale_factor",1],PARAMETER["false_easting",0],PARAMETER["false_northing",0],UNIT["metre",1,AUTHORITY["EPSG","9001"]],AXIS["Easting",EAST],AXIS["Northing",NORTH],EXTENSION["PROJ4","+proj=merc +a=6378137 +b=6378137 +lat_ts=0 +lon_0=0 +x_0=0 +y_0=0 +k=1 +units=m +nadgrids=@null +wktext +no_defs"],AUTHORITY["EPSG","3857"]]GeoTransform :-10545558.188164568 10.0 0.0 5177920.753919057 0.0 -10.0array(0)Data variables: (1)variables(time, band, y, x)float32105.8 106.5 105.1 ... -27.13 -4.077array([[[[ 1.05770493e+02, 1.06525818e+02, 1.05133469e+02, ..., 1.30700256e+02, 1.30074829e+02, 1.27931900e+02], [ 6.08201904e+01, 6.29569931e+01, 6.25954361e+01, ..., 1.28005692e+02, 1.27422653e+02, 1.24593033e+02], [ 5.44661789e+01, 5.69338531e+01, 5.75790405e+01, ..., 1.21588974e+02, 1.20458359e+02, 1.17762802e+02], ..., [ 1.51214890e+02, 1.52101990e+02, 1.52024200e+02, ..., -1.18089600e+02, -1.20491562e+02, -1.31861267e+02], [ 1.51108643e+02, 1.51166306e+02, 1.50360260e+02, ..., -1.18652443e+02, -1.20392769e+02, -1.32454071e+02], [ 1.51752594e+02, 1.51912766e+02, 1.51645416e+02, ..., -1.14830399e+02, -1.16933640e+02, -1.29588287e+02]], [[ 6.51836700e+01, 6.41575317e+01, 6.36946754e+01, ..., 1.66106911e+01, 1.59068413e+01, 1.49540596e+01], [ 7.97899323e+01, 7.65455933e+01, 7.61807556e+01, ..., 1.71908302e+01, 1.67538795e+01, 1.62054939e+01], [ 8.03892975e+01, 7.73186646e+01, 7.53374481e+01, ..., 1.97710686e+01, 2.10001564e+01, 2.09486656e+01], ... -1.97053558e+02, -1.97430145e+02, -1.92435471e+02], [ 5.55389061e+01, 5.58998604e+01, 5.56245155e+01, ..., -1.97187149e+02, -1.98246323e+02, -1.93215683e+02], [ 5.47693748e+01, 5.49577599e+01, 5.39914284e+01, ..., -1.96610718e+02, -1.99787994e+02, -1.99223740e+02]], [[ 1.27576866e+01, 4.09418488e+00, 2.94165802e+00, ..., -5.68252563e-01, -8.00636292e-01, -8.35319519e-01], [ 1.52739182e+01, 1.24224472e+01, 1.14384575e+01, ..., -4.63180542e-01, -1.17514038e+00, -2.06540680e+00], [ 1.39552650e+01, 1.15149002e+01, 1.13042603e+01, ..., -1.52838898e+00, -2.34008026e+00, -3.25760651e+00], ..., [ 1.41902199e+01, 1.48776703e+01, 1.45605927e+01, ..., -1.11963120e+01, -1.61422577e+01, 5.11234283e+00], [ 1.08453636e+01, 1.15880547e+01, 1.06670532e+01, ..., -1.37364197e+01, -1.82680054e+01, 2.96572113e+00], [ 1.08456039e+01, 1.12658768e+01, 1.04980927e+01, ..., -2.48144073e+01, -2.71259003e+01, -4.07700348e+00]]]], shape=(9, 3, 1024, 2048), dtype=float32) low, high = robust_params(array) frames = [to_uint8_robust(array.sel(time=t), low, high) for t in array.time] iio.imwrite("assets/rgb_animation_pca.gif", frames, duration=1000, loop=0) The PCA animation makes temporal structure easier to see than the raw RGB view. Field boundaries and repeated change patterns stand out more clearly, while nearby urban areas show a different signature of structural change. Compared with the first three raw bands, the PCA view surfaces a more coherent pattern of cyclical change. In agricultural areas, the alternating shifts between green and brown tones likely correspond to crop rotations, harvest cycles, irrigation, or other recurring management practices. PCA is still a simplification, and it comes with tradeoffs: PCA is a linear transformation, so it may miss more complex relationships in the embedding space. PCA can be sensitive to outliers and to the sample used to fit the model. PCA adds computational overhead because fitting and applying the transform requires additional matrix operations. Another option is to stay in the original embedding space and measure distance between embeddings over time. That gives us a direct way to ask how similar or dissimilar each pixel is from one timestep to the next. Measure Change Directly in Embedding Space Here we use an experimental RasterFlow workflow to compute distance over time in the original embedding space. This workflow is not yet publicly available, but it is useful for exploring whether similarity metrics can reveal patterns that PCA may smooth over. If you are interested in this approach, please reach out to the Wherobots team (support@wherobots.com). For a field-level aggregation approach using the same embeddings, see Zonal Statistics on AlphaEarth Embeddings. index = gpd.read_parquet( "s3://union-sandbox-unionai-wherobots/dw/wherobots/wherobots-rasterflow/development/rfzhkxdjv782gtq9xtzz/a2dwp687c37a3kc1z6593pdax/1/ae/famfddly/0fbb239f05eb805bf4128b8be91316ce/" ) # we look at an exmple mosaic for now ex_store = index.iloc[0]["location"] ds = xr.open_zarr(ex_store).compute() ds <xarray.Dataset> Size: 533MB Dimensions: (time: 8, band: 1, y: 3020, x: 5512) Coordinates: * time (time) datetime64[ns] 64B 2018-01-01 2019-01-01 ... 2025-01-01 * band (band) object 8B 'cosine_distance' * y (y) float64 24kB 5.178e+06 5.178e+06 ... 5.148e+06 5.148e+06 * x (x) float64 44kB -1.055e+07 -1.055e+07 ... -1.049e+07 spatial_ref int64 8B 0 Data variables: variables (time, band, y, x) float32 533MB 0.188 0.1655 ... 0.02265xarray.DatasetDimensions:time: 8band: 1y: 3020x: 5512Coordinates: (5)time(time)datetime64[ns]2018-01-01 ... 2025-01-01array(['2018-01-01T00:00:00.000000000', '2019-01-01T00:00:00.000000000', '2020-01-01T00:00:00.000000000', '2021-01-01T00:00:00.000000000', '2022-01-01T00:00:00.000000000', '2023-01-01T00:00:00.000000000', '2024-01-01T00:00:00.000000000', '2025-01-01T00:00:00.000000000'], dtype='datetime64[ns]')band(band)object'cosine_distance'array(['cosine_distance'], dtype=object)y(y)float645.178e+06 5.178e+06 ... 5.148e+06array([5177915.753919, 5177905.753919, 5177895.753919, ..., 5147745.753919, 5147735.753919, 5147725.753919], shape=(3020,))x(x)float64-1.055e+07 ... -1.049e+07array([-10545553.188165, -10545543.188165, -10545533.188165, ..., -10490463.188165, -10490453.188165, -10490443.188165], shape=(5512,))spatial_ref()int640crs_wkt :PROJCS["WGS 84 / Pseudo-Mercator",GEOGCS["WGS 84",DATUM["WGS_1984",SPHEROID["WGS 84",6378137,298.257223563,AUTHORITY["EPSG","7030"]],AUTHORITY["EPSG","6326"]],PRIMEM["Greenwich",0,AUTHORITY["EPSG","8901"]],UNIT["degree",0.0174532925199433,AUTHORITY["EPSG","9122"]],AUTHORITY["EPSG","4326"]],PROJECTION["Mercator_1SP"],PARAMETER["central_meridian",0],PARAMETER["scale_factor",1],PARAMETER["false_easting",0],PARAMETER["false_northing",0],UNIT["metre",1,AUTHORITY["EPSG","9001"]],AXIS["Easting",EAST],AXIS["Northing",NORTH],EXTENSION["PROJ4","+proj=merc +a=6378137 +b=6378137 +lat_ts=0 +lon_0=0 +x_0=0 +y_0=0 +k=1 +units=m +nadgrids=@null +wktext +no_defs"],AUTHORITY["EPSG","3857"]]spatial_ref :PROJCS["WGS 84 / Pseudo-Mercator",GEOGCS["WGS 84",DATUM["WGS_1984",SPHEROID["WGS 84",6378137,298.257223563,AUTHORITY["EPSG","7030"]],AUTHORITY["EPSG","6326"]],PRIMEM["Greenwich",0,AUTHORITY["EPSG","8901"]],UNIT["degree",0.0174532925199433,AUTHORITY["EPSG","9122"]],AUTHORITY["EPSG","4326"]],PROJECTION["Mercator_1SP"],PARAMETER["central_meridian",0],PARAMETER["scale_factor",1],PARAMETER["false_easting",0],PARAMETER["false_northing",0],UNIT["metre",1,AUTHORITY["EPSG","9001"]],AXIS["Easting",EAST],AXIS["Northing",NORTH],EXTENSION["PROJ4","+proj=merc +a=6378137 +b=6378137 +lat_ts=0 +lon_0=0 +x_0=0 +y_0=0 +k=1 +units=m +nadgrids=@null +wktext +no_defs"],AUTHORITY["EPSG","3857"]]GeoTransform :-10545558.188164568 10.0 0.0 5177920.753919057 0.0 -10.0array(0)Data variables: (1)variables(time, band, y, x)float320.188 0.1655 ... 0.01864 0.02265array([[[[0.18804508, 0.16548318, 0.1549539 , ..., 0.6547667 , 0.6600232 , 0.6827302 ], [0.09239805, 0.08352977, 0.07957667, ..., 0.65431345, 0.6571518 , 0.6796979 ], [0.07838935, 0.07207382, 0.07041121, ..., 0.64434373, 0.643643 , 0.67302835], ..., [0.0281769 , 0.02823567, 0.02864486, ..., 0.32737243, 0.3262853 , 0.30775493], [0.04068887, 0.04150879, 0.03881335, ..., 0.3269202 , 0.3267812 , 0.31013072], [0.04347068, 0.04405069, 0.04101866, ..., 0.3367141 , 0.33538705, 0.3183689 ]]], [[[0.20149559, 0.17581868, 0.16398937, ..., 0.4470787 , 0.448758 , 0.45887315], [0.09369123, 0.08462119, 0.07994598, ..., 0.44638008, 0.44314015, 0.45811075], [0.08294475, 0.07634479, 0.07309014, ..., 0.44814014, ... [0.04521638, 0.04491764, 0.04381305, ..., 0.46263814, 0.45893395, 0.4046142 ], [0.04455835, 0.04325521, 0.04292834, ..., 0.46341467, 0.4581054 , 0.40957177]]], [[[0.49459922, 0.46871775, 0.4611882 , ..., 0.1296547 , 0.12676072, 0.12663496], [0.4966467 , 0.4909042 , 0.4853648 , ..., 0.1275351 , 0.1253922 , 0.1257289 ], [0.4927944 , 0.4910075 , 0.48507315, ..., 0.12276459, 0.11952102, 0.12123233], ..., [0.08114195, 0.08070195, 0.08397031, ..., 0.01797432, 0.01881957, 0.02318203], [0.08387047, 0.0837459 , 0.08279037, ..., 0.01825058, 0.01814717, 0.02346212], [0.08379203, 0.08498359, 0.08619195, ..., 0.01759416, 0.01863509, 0.02264881]]]], shape=(8, 1, 3020, 5512), dtype=float32) array = ds["variables"][:, :, :, :] low = np.nanpercentile(array[0].values, 0) high = np.nanpercentile(array[0].values, 50) frames = [to_uint8_robust(array.sel(time=t)[0], low, high)[:, :] for t in array.time] iio.imwrite("assets/distance_over_time_50p.gif", frames, duration=1000, loop=0) In the animation above, darker pixels indicate embeddings that remain more similar over time, while lighter pixels indicate larger temporal differences. Interpretation is still evolving, but the visualization suggests several useful patterns. Persistent bright pixels may mark locations with repeated or abrupt change. Alternating light-dark cycles may indicate recurring agricultural activity such as planting, harvest, or irrigation. This does not replace domain knowledge, but it provides another lens for understanding how field-scale practices appear in AlphaEarth Foundations embeddings. What AlphaEarth Foundations Embeddings Show About Field-Level Dynamics AlphaEarth Foundations embeddings make it possible to examine agricultural change as a time series in embedding space rather than only as raw imagery. In this notebook, we built Zarr mosaics for several AOIs, visualized temporal variation in the first three embedding bands, projected the embeddings with PCA, and then compared that view with a distance-over-time workflow. Together, these helped draw insight into a few of the many ways AlphaEarth Foundations embeddings (and others) can provide insight into field-level dynamics. Check out Change Detection Using AlphaEarth Foundations (Part 2). Get Started with RasterFlow Start Building Key takeawaysAlphaEarth Foundations (Google DeepMind) compresses a year of satellite observations into 64-dimensional embedding vectors for every 10-meter pixel on Earth's land surfaces and shallow coastal waters, annually. This notebook inspects field-level change in embedding space rather than relying only on imagery.Three agricultural AOIs are used: Iowa, California, and Japan. RasterFlow build_gti_mosaic builds a Zarr mosaic per AOI from AlphaEarth Foundations COGs on Source Cooperative. An example Iowa mosaic is 38 GB (9 time steps, 64 bands, 2017–2025).Three views of temporal change: the first three embedding bands as RGB, a PCA projection of all 64 bands into three components, and an experimental cosine-distance-over-time workflow. PCA is described as making field-level seasonal structure easier to see than raw RGB bands.The goal is not a single definitive interpretation of AEF. It is a practical workflow for exploring seasonal cycles, repeated interventions, and other field-scale dynamics in embedding space, using experimental RasterFlow PCA and distance-over-time tools.