Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
359 changes: 354 additions & 5 deletions crates/engine/src/cmd/embeddings.rs

Large diffs are not rendered by default.

67 changes: 39 additions & 28 deletions crates/engine/src/cmd/stac.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1160,34 +1160,7 @@ fn add_cube(s: &mut Session, p: &Value) -> CmdResult {
}
}
}
// The slider over the cube's dates: on over them when it is off; when it is on, taking
// them in (its own dates and the cube's, or its span widened to theirs), so every file
// can be reached.
let slider = match s.map().time.as_ref() {
None => {
let d: Vec<Value> = dates.iter().map(|d| json!(d)).collect();
json!({"dates": d})
}
Some(t) if !t.dates.is_empty() => {
let mut all: Vec<f64> = t.dates.iter().chain(&dates).copied().collect();
all.sort_by(f64::total_cmp);
all.dedup();
if all.len() > rustgis_map::time::MAX_STEPS {
undo(s);
return Err(format!(
"the time slider's dates and the cube's make {}, more than its {} steps: take a shorter period",
all.len(),
rustgis_map::time::MAX_STEPS
));
}
json!({"dates": all, "date": t.current})
}
Some(t) => {
let (first, last) = (dates.first().copied().unwrap_or(t.start), dates.last().copied().unwrap_or(t.end));
json!({"start": t.start.min(first), "end": t.end.max(last), "date": t.current})
}
};
if let Err(e) = super::time::set(s, &slider) {
if let Err(e) = cube_slider(s, &mut dates, &json!({})) {
undo(s);
return Err(e);
}
Expand All @@ -1206,6 +1179,44 @@ fn add_cube(s: &mut Session, p: &Value) -> CmdResult {
}
}

/// Turns the time slider on over a data cube's dates (sorted and deduplicated here), or, when it
/// is on, takes them in (its own dates and the cube's, or its span widened to theirs), so every
/// file can be reached. `fresh`: what else a slider turned on here is set to (AlphaEarth's years
/// shown as years). Refused, the slider unchanged, when the dates are more than its steps: a cube
/// left with files the slider can't reach would say nothing of them.
pub(crate) fn cube_slider(s: &mut Session, dates: &mut Vec<f64>, fresh: &Value) -> Result<(), String> {
dates.sort_by(f64::total_cmp);
dates.dedup();
if dates.len() > rustgis_map::time::MAX_STEPS {
return Err(format!("{} dates; the time slider takes {} at most", dates.len(), rustgis_map::time::MAX_STEPS));
}
let slider = match s.map().time.as_ref() {
None => {
let mut q = if fresh.is_object() { fresh.clone() } else { json!({}) };
q["dates"] = json!(dates);
q
}
Some(t) if !t.dates.is_empty() => {
let mut all: Vec<f64> = t.dates.iter().chain(dates.iter()).copied().collect();
all.sort_by(f64::total_cmp);
all.dedup();
if all.len() > rustgis_map::time::MAX_STEPS {
return Err(format!(
"the time slider's dates and the cube's make {}, more than its {} steps: take a shorter period",
all.len(),
rustgis_map::time::MAX_STEPS
));
}
json!({"dates": all, "date": t.current})
}
Some(t) => {
let (first, last) = (dates.first().copied().unwrap_or(t.start), dates.last().copied().unwrap_or(t.end));
json!({"start": t.start.min(first), "end": t.end.max(last), "date": t.current})
}
};
super::time::set(s, &slider).map(|_| ())
}

/// The files a cube's range is sampled from, at most, and the most pixels a side read from each.
#[cfg(not(target_arch = "wasm32"))]
const SAMPLE_FILES: usize = 4;
Expand Down
10 changes: 8 additions & 2 deletions crates/engine/src/cmd/tiles.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ pub const NETWORK: &[&str] = &[
"portal.files",
"embeddings.search",
"embeddings.add",
"embeddings.addCube",
"earthdata.collections",
"earthdata.granules",
"nldi.flowline",
Expand Down Expand Up @@ -982,9 +983,14 @@ fn set(s: &mut Session, p: &Value) -> CmdResult {
t.name = r.chars().take(120).collect();
}
if let Some(url) = new_url {
// A dated layer keeps the look at every date: its template takes it too.
// A dated layer keeps the look at every date: its template takes it too, and a data
// cube's file of each date (the slider would bring the old look back at the next step).
let look = cog::split(&url).1;
if let Some(template) = t.time_url.as_mut() {
*template = cog::with_look(template, &cog::split(&url).1);
*template = cog::with_look(template, &look);
}
for (_, u) in &mut t.time_urls {
*u = cog::with_look(u, &look);
}
t.url = url;
}
Expand Down
209 changes: 205 additions & 4 deletions crates/tiles/src/embeddings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
//! Nothing here waits on the network: searches and reads take a callback (the app's, off the
//! frame), with blocking forms for the command line.

use std::collections::BTreeMap;

use rustgis_data::source::{Batch, Request, parquet::Vectors};
use rustgis_data::{Dataset, Feature, Field, FieldType, Geometry, GeometryType, Value};
use rustgis_geom::{Coord, Extent};
Expand Down Expand Up @@ -273,6 +275,14 @@ fn year_of(t: i64) -> i32 {
/// The tiles of a year in what the index read, nearest the box's centre first (at most
/// [`MAX_TILES`]).
pub fn aef_tiles(fields: &[Field], batch: &Batch, year: i32, bbox: [f64; 4]) -> Vec<Tile> {
let mut out = aef_rows(fields, batch, bbox, |y| y == year);
out.dedup_by(|a, b| a.1.url == b.1.url);
out.into_iter().take(MAX_TILES).map(|(_, t)| t).collect()
}

/// Every tile of the years `keep` takes in what the index read, each titled with its own year,
/// with its squared distance from the box's centre, nearest first.
fn aef_rows(fields: &[Field], batch: &Batch, bbox: [f64; 4], keep: impl Fn(i32) -> bool) -> Vec<(f64, Tile)> {
let col = |n: &str| fields.iter().position(|f| f.name == n);
let (href, epsg, date, id) = (col(AEF_HREF), col("proj:epsg"), col("datetime"), col("id"));
let text = |f: &Feature, c: Option<usize>| c.and_then(|c| f.attrs.get(c)).map(|v| v.to_string()).unwrap_or_default();
Expand All @@ -281,12 +291,12 @@ pub fn aef_tiles(fields: &[Field], batch: &Batch, year: i32, bbox: [f64; 4]) ->
.features
.iter()
.filter_map(|f| {
let y = date.and_then(|c| f.attrs.get(c)).and_then(|v| match v {
let year = date.and_then(|c| f.attrs.get(c)).and_then(|v| match v {
Value::Int(t) => Some(year_of(*t)),
Value::Text(t) => t.get(..4).and_then(|y| y.parse().ok()),
_ => None,
})?;
if y != year {
if !keep(year) {
return None;
}
let url = https(&text(f, href));
Expand Down Expand Up @@ -327,8 +337,113 @@ pub fn aef_tiles(fields: &[Field], batch: &Batch, year: i32, bbox: [f64; 4]) ->
})
.collect();
out.sort_by(|a, b| a.0.total_cmp(&b.0));
out.dedup_by(|a, b| a.1.url == b.1.url);
out.into_iter().take(MAX_TILES).map(|(_, t)| t).collect()
out
}

/// One AlphaEarth tile over the years. Each year's export names its files afresh (a prefix of
/// its own: `xdwdu82ch9yffuj03-0000008192-0000000000.tiff` in 2017,
/// `x1i7lpngfblt9wshv-0000008192-0000000000.tiff` in 2024), so a tile's years are matched by
/// place: its coordinate system and footprint.
#[derive(Clone, Debug, PartialEq)]
pub struct Series {
/// The tile as a card shows it: its latest year's footprint, titled with its years.
pub tile: Tile,
/// Each year's file.
pub years: BTreeMap<i32, String>,
}

/// What a search over the years found.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct SeriesFound {
/// The tiles, nearest the box's centre first (at most [`MAX_TILES`]).
pub series: Vec<Series>,
/// Tiles past [`MAX_TILES`], left out.
pub dropped: usize,
/// Rows left out because their tile already had another file that year.
pub duplicates: usize,
/// Whether the index was read whole for the box.
pub complete: bool,
}

/// How far apart (degrees, about 10 m) two years' footprint corners may be and still be one
/// tile's: far above the noise of the index's coordinates, far below a tile's 82 km, so no
/// rounding boundary splits a tile in two.
const SAME_PLACE: f64 = 1e-4;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Matching tolerance, confidence low-moderate (not verified against the index). The comment justifies SAME_PLACE = 1e-4° by "far below a tile's 82 km", but the risk it has to cover is the other way: how much one tile's footprint can move between years. One 10 m pixel is about 9e-5° of latitude and about 1e-4° of longitude at Viçosa's latitude, so a footprint that shifts by two pixels between years would not match. That would split one tile into separate series, and the cube would show two layers with gaps instead of one. The gap would be reported as "tile-years have no file", but the cause would be misreported. The test only covers a 1e-5° offset. Suggest either a test with a 2–3 pixel shift, or a note on what the index's footprints were measured to do across years.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I checked this against the real index. I ran embeddings.search for each year from 2017 to 2025 in three areas of Brazil:

  • around Viçosa, MG: [-43.5, -21.2, -42.3, -20.3]
  • across the UTM 23/24 zone line: [-42.3, -21.0, -41.5, -20.2]
  • inland São Paulo: [-47.5, -23.2, -46.3, -22.3]

Then I grouped the rows by zone, the file's row/column suffix and the area (the name prefix changes every year), and compared the footprints.

Result: 16 tiles, 9 years each, 144 rows. Every tile's footprint is the same in all nine years, bit for bit. The largest corner spread, across all tiles and corners, is 0.0°. The index draws each year's export on the same UTM grid (8192 px tiles from a fixed origin per zone), so a tile doesn't move between years.

So 1e-4° covers float noise in the index's coordinates, which is all there is, and it is far from the 0.74° between neighbouring tiles. The comment's "far below a tile's 82 km" is about not merging neighbours. The other side, how far one tile moves between years, is 0 here. If a future export ever shifted a tile, the series would show up as two tiles with their years split, which the "N of M years" detail would make visible. No change in this PR.


/// Whether two rows are one tile's: the same coordinate system, and footprints whose corners
/// agree to [`SAME_PLACE`].
fn same_place(a: &Tile, b: &Tile) -> bool {
fn crs(t: &Tile) -> &str {
t.detail.split(" · ").next().unwrap_or("")
}
a.bbox.iter().zip(b.bbox.iter()).all(|(x, y)| (x - y).abs() <= SAME_PLACE) && crs(a) == crs(b)
}

/// The tiles of the years `first` to `last` in what the index read, each with its file a year,
/// nearest the box's centre first.
pub fn aef_series(fields: &[Field], batch: &Batch, (first, last): (i32, i32), bbox: [f64; 4]) -> SeriesFound {
let mut found = SeriesFound { complete: batch.complete, ..Default::default() };
// Each tile's place as first seen: matched against, so a tile's latest footprint taking over
// its card doesn't move what later rows are matched to. A read holds at most MAX_INDEX_ROWS
// rows, so comparing each with the tiles so far is cheap.
let mut places: Vec<Tile> = Vec::new();
for (_, t) in aef_rows(fields, batch, bbox, |y| (first..=last).contains(&y)) {
let Some(year) = t.year else { continue };
let i = match places.iter().position(|p| same_place(p, &t)) {
Some(i) => i,
None => {
places.push(t.clone());
found.series.push(Series { tile: t.clone(), years: BTreeMap::new() });
found.series.len() - 1
}
};
let Some(s) = found.series.get_mut(i) else { continue };
match s.years.get(&year) {
Some(u) if *u != t.url => found.duplicates += 1,
Some(_) => {}
None => {
if s.tile.year.is_none_or(|y| year > y) {
s.tile = t.clone();
}
s.years.insert(year, t.url);
}
}
}
for s in &mut found.series {
let (a, z) = (s.years.keys().next().copied().unwrap_or(first), s.years.keys().next_back().copied().unwrap_or(last));
let zone = s.tile.title.split(" · ").nth(1).map(|z| format!(" · {z}")).unwrap_or_default();
let crs = s.tile.detail.split(" · ").next().unwrap_or("").to_string();
s.tile.title = if a == z { format!("AlphaEarth {a}{zone}") } else { format!("AlphaEarth {a}–{z}{zone}") };
s.tile.detail = format!("{crs} · 64 bands · 10 m · {} of {} years", s.years.len(), last - first + 1);
s.tile.year = None;
}
found.dropped = found.series.len().saturating_sub(MAX_TILES);
found.series.truncate(MAX_TILES);
found
}

/// The first moment of a year, in milliseconds since 1970 (UTC): a time slider's step for it.
pub fn year_start_ms(year: i32) -> f64 {
// Howard Hinnant's days_from_civil for 1 January, which it counts as month 11 of the year
// before (March is its month 1).
let y = i64::from(year) - 1;
let era = y.div_euclid(400);
let yoe = y - era * 400;
let doy = (153 * 10 + 2) / 5;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
let days = era * 146_097 + doe - 719_468;
days as f64 * 86_400_000.0
}

/// An AlphaEarth file's place, as a layer's name gives it: its UTM zone's folder and its tile's
/// row and column (`23S 0000008192-0000000000` from `…/2024/23S/x1i7…-0000008192-0000000000.tiff`).
pub fn aef_place(url: &str) -> Option<String> {
let path = url.split(['?', '#']).next().unwrap_or(url);
let mut parts = path.rsplit('/');
let file = parts.next()?.trim_end_matches(".tiff").trim_end_matches(".tif");
let zone = parts.next().filter(|z| !z.is_empty())?;
let (_, place) = file.split_once('-')?;
Some(format!("{zone} {place}"))
}

/// What a search found: the tiles, and whether the index was read whole for the box (a box
Expand Down Expand Up @@ -364,6 +479,30 @@ pub fn search_aef_blocking(bbox: [f64; 4], year: i32) -> Result<Found, String> {
Ok((aef_tiles(&fields, &batch, year, bbox), batch.complete))
}

/// Finds AlphaEarth's tiles in a box over the years `first` to `last`, each with its file a year:
/// the same one read of the index as [`search_aef`] (its rows hold every year).
pub fn search_aef_series(bbox: [f64; 4], years: (i32, i32), done: Box<dyn FnOnce(Result<SeriesFound, String>) + Send>) {
let bbox = match check_bbox(bbox) {
Ok(b) => b,
Err(e) => return done(Err(e)),
};
rustgis_data::source::query_url(
AEF_INDEX,
vec![AEF_HREF.into()],
aef_request(bbox),
Box::new(move |r| done(r.map(|(fields, batch)| aef_series(&fields, &batch, years, bbox)).map_err(|e| e.to_string()))),
);
}

/// [`search_aef_series`], waiting for it (the command line).
#[cfg(not(target_arch = "wasm32"))]
pub fn search_aef_series_blocking(bbox: [f64; 4], years: (i32, i32)) -> Result<SeriesFound, String> {
let bbox = check_bbox(bbox)?;
let (fields, batch) =
rustgis_data::source::query_url_blocking(AEF_INDEX, vec![AEF_HREF.into()], aef_request(bbox)).map_err(|e| e.to_string())?;
Ok(aef_series(&fields, &batch, years, bbox))
}

// Earth Index.

/// Where Earth Index's files are.
Expand Down Expand Up @@ -801,6 +940,68 @@ mod tests {
assert!(aef_tiles(&[], &batch, 2021, [0.0, 0.0, 1.0, 1.0]).is_empty());
}

/// A tile's years are matched by place, not by name (each year's export names its files
/// afresh); another place is another tile, a second file for a tile's year is counted.
#[test]
fn index_rows_to_series() {
let fields = vec![
Field::new("id", FieldType::Text),
Field::new("datetime", FieldType::Integer),
Field::new(AEF_HREF, FieldType::Text),
Field::new("proj:epsg", FieldType::Text),
];
let row = |x: f64, year: i32, href: &str| Feature {
id: 1,
geometry: Some(Geometry::Polygon(vec![vec![vec![
Coord::new(x, -21.0),
Coord::new(x + 0.8, -21.0),
Coord::new(x + 0.8, -20.7),
Coord::new(x, -20.7),
Coord::new(x, -21.0),
]]])),
attrs: vec![
Value::Text("1".into()),
Value::Int(year_start_ms(year) as i64),
Value::Text(href.into()),
Value::Text("EPSG:32723".into()),
],
};
let base = "s3://us-west-2.opendata.source.coop/tge-labs/aef/v1/annual";
let batch = Batch {
features: vec![
// A footprint a hair off another year's, across a 1e-5° rounding boundary: the
// same tile still.
row(-43.400_005_1, 2024, &format!("{base}/2024/23S/xb-0000008192-0000000000.tiff")),
row(-43.4, 2017, &format!("{base}/2017/23S/xa-0000008192-0000000000.tiff")),
row(-43.4, 2025, &format!("{base}/2025/23S/xc-0000008192-0000000000.tiff")),
// The same tile and year twice: the first kept, the other counted.
row(-43.4, 2025, &format!("{base}/2025/23S/xd-0000008192-0000000000.tiff")),
// Another place; and a year outside those asked for.
row(-44.2, 2020, &format!("{base}/2020/23S/xe-0000000000-0000000000.tiff")),
row(-43.4, 2016, &format!("{base}/2016/23S/xf-0000008192-0000000000.tiff")),
],
matched: 6,
complete: false,
skipped: 0,
};
let f = aef_series(&fields, &batch, (2017, 2025), [-43.0, -20.9, -42.9, -20.8]);
assert_eq!((f.series.len(), f.duplicates, f.dropped, f.complete), (2, 1, 0, false));
let s = &f.series[0];
assert_eq!(s.years.keys().copied().collect::<Vec<_>>(), vec![2017, 2024, 2025]);
assert_eq!(s.years[&2025], "https://data.source.coop/tge-labs/aef/v1/annual/2025/23S/xc-0000008192-0000000000.tiff");
assert_eq!((s.tile.title.as_str(), s.tile.year), ("AlphaEarth 2017–2025 · UTM 23S", None));
assert_eq!(s.tile.detail, "EPSG:32723 · 64 bands · 10 m · 3 of 9 years");
assert_eq!(s.tile.id, "xc-0000008192-0000000000", "the latest year's file stands for the tile");
assert_eq!(f.series[1].tile.title, "AlphaEarth 2020 · UTM 23S");
assert_eq!(aef_place(&s.years[&2017]).as_deref(), Some("23S 0000008192-0000000000"));
assert_eq!(aef_place("https://h/x.tiff#bands=1"), None);
assert_eq!(year_start_ms(1970), 0.0);
assert_eq!(year_start_ms(2017), 1_483_228_800_000.0);
assert_eq!(year_start_ms(2024), 1_704_067_200_000.0);
assert_eq!(year_of(year_start_ms(2000) as i64), 2000);
assert_eq!(aef_series(&[], &batch, (2017, 2025), [0.0, 0.0, 1.0, 1.0]).series, Vec::new());
}

#[test]
fn principal_component_colours() {
// Two clusters along one direction: their first component tells them apart.
Expand Down
2 changes: 1 addition & 1 deletion crates/ui-egui/src/chrome.rs
Original file line number Diff line number Diff line change
Expand Up @@ -943,7 +943,7 @@ fn search_action(id: &str) -> Option<SearchAction> {
"osm.download" => Dialog("osm"),
"overture.add" => Dialog("overture"),
"boundaries.add" => Dialog("boundaries"),
"embeddings.add" | "embeddings.search" => Dialog("embeddings"),
"embeddings.add" | "embeddings.addCube" | "embeddings.search" => Dialog("embeddings"),
"cube.catalog" | "cube.open" => Dialog("dataCubes"),
"nationalMap.add" => Dialog("nationalMap"),
"fieldsOfTheWorld.add" => Dialog("fieldsOfTheWorld"),
Expand Down
Loading
Loading