Repository navigation
feat(embeddings): AlphaEarth's years on the time slider (embeddings.addCube) (#1720) - #1749
elpidioassinaturas wants to merge 10 commits into
Conversation
…ddCube) (opengeos#1720) - tiles: search_aef_series / aef_series find each AlphaEarth tile's file of every year in one read of the GeoParquet index, matched by place (each year's export names its files afresh); year_start_ms, aef_place. - engine: embeddings.addCube adds a tile layer per tile with a COG per year in time_urls, the slider on over the years (YYYY), as stac.addCube does; missing tile-years, a partial index read, tiles past a search's list and a second file for a year are said. The slider part of stac.addCube is shared as cube_slider. - map.setTiles gives a new look (bands, range) to every file of a data cube, not only the one shown. - ui: Satellite Embeddings › All years, on the time slider, Add years, and Cancel while a search or read runs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Pre-merge checks:
|
|
|
||
| /// What a search over the years left out: rows past one read, tiles past a search's list, and a | ||
| /// tile's second file for a year. | ||
| fn series_notices(s: &mut Session, f: &emb::SeriesFound) { |
There was a problem hiding this comment.
Bug (wasm CI break), confidence high. series_notices is only called from the #[cfg(not(target_arch = "wasm32"))] block in search_series (line 333), so on wasm32 it is never used. ci.yml sets RUSTFLAGS: -D warnings for the wasm build (cargo build --target wasm32-unknown-unknown -p rustgis-web), so this dead_code warning fails that job.
Small fix: gate the function the same way as its only caller.
| fn series_notices(s: &mut Session, f: &emb::SeriesFound) { | |
| #[cfg(not(target_arch = "wasm32"))] | |
| fn series_notices(s: &mut Session, f: &emb::SeriesFound) { |
There was a problem hiding this comment.
Fixed in a3bf404: series_notices is gated with #[cfg(not(target_arch = "wasm32"))], like its caller. cargo check --locked --target wasm32-unknown-unknown -p rustgis-web with RUSTFLAGS=-D warnings passes locally.
| undo(s); | ||
| return Err(e); | ||
| } | ||
| if missing > 0 { |
There was a problem hiding this comment.
Quality / accuracy of the notice, confidence moderate. missing counts every tile-year without a file among the rows that were read, and the message says these have "no file in AlphaEarth's index". When the read was cut short (SeriesFound.complete is false, which search_series reports only in its own notice), a tile-year can be missing because the read stopped, not because the index has no file. The count and the wording then disagree with what happened.
Suggest carrying complete from search_series into this message, e.g. "…have no file in the rows of AlphaEarth's index that were read (zoom in to read the rest)" when the read was incomplete. A test for an incomplete read would cover it.
There was a problem hiding this comment.
Fixed in a3bf404. search_series now returns whether the read was whole. With found, the pane hands over its search's own complete; it is a new internal parameter that defaults to true. When the read was cut short, the notice says "…have no file in the part of AlphaEarth's index that was read (zoom in to read it whole)…". alphaearth_years_on_the_time_slider covers it with "complete": false.
Code reviewBugs
Security
Performance
Quality
AGENTS.md
Prior reviews: no review threads exist on this PR, so nothing was left out as already raised. Change sizeCompared with the base branch (merge base e7d26e4 to PR head fd0a0fd, the whole PR, not the latest push).
|
📖 Docs preview
GitHub Pages takes a minute or two to publish a new commit. |
- series_notices is native only, as its one caller: the wasm32 build passes with -D warnings. - add_cube's doc says why it runs on its caller's thread without found. - The missing-years notice tells an incomplete read from the index's own gap; the pane hands its search's completeness over (complete). - A tile handed over with no file in the years asked for is left out and said. - A tile's years are matched by footprints agreeing to 1e-4 degrees, not by a rounded key a rounding boundary could split. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Thanks for the review. All of it is in a3bf404, and main is merged in 9f0338f.
|
| }); | ||
| } | ||
| if cancel { | ||
| // The read runs on, but what it brings is dropped with its slot. |
There was a problem hiding this comment.
AGENTS.md (Cancel), confidence moderate. Cancel only drops the result. For a search or series read (Read::Search / Read::Series) nothing tells query_url to stop, so the index read keeps running after the button is pressed. The read is bounded (MAX_INDEX_ROWS), so this is a cost, not a hang. But the repo rule is that Cancel stops the work, and this comment is the only thing that says it doesn't. Either pass a cancel flag into the query, or state in the doc comment why a bounded read is left running.
There was a problem hiding this comment.
Right: for a search or a series read, Cancel drops the result and nothing tells query_url to stop. The read is bounded (one read of the index, at most MAX_INDEX_ROWS rows), and every existing search in this pane works the same way, so this PR keeps that behaviour. 7b4131f updates the comment at the Cancel button to say this. #1726 brings per-download progress and Cancel in the status bar. Once it lands, these reads can move onto it in a follow-up PR, so that Cancel stops the read too.
| for (y, u) in t.get("years").and_then(Value::as_object).ok_or_else(bad)? { | ||
| let y: i32 = y.trim().parse().map_err(|_| bad())?; | ||
| let u = u.as_str().map(str::trim).filter(|u| u.starts_with("https://") && u.len() <= 4000).ok_or_else(bad)?; | ||
| if (start..=end).contains(&y) { |
There was a problem hiding this comment.
AGENTS.md (Never silent), confidence moderate. A year outside start..=end in a handed-over tile is dropped without a notice. A caller (CLI, assistant, plugin) that passes a 2016 file with start: 2017 loses that file silently. Either count the dropped years and report them through s.notify, or say in the doc comment that the filter is deliberate. The pane never triggers this, since its years come from the same range.
There was a problem hiding this comment.
Confirmed, and fixed in 7b4131f. Before the fix, the existing test's 2016 file (with start 2017) went without a notice: a new assertion failed on it. found() now counts the files outside start..=end, and the command says "N files handed over are outside start–end and were left out", next to its other notices. alphaearth_years_on_the_time_slider covers it.
| /// of the time slider. Each tile is a tile layer reading its file of the year the slider is at | ||
| /// (`time_urls`, as `stac.addCube` makes them); a year a tile has no file for shows nothing, said. | ||
| /// The tiles come from one read of the index, which holds every year (the command line waits for | ||
| /// it), or from the app's search off the frame (`found`). Natively only the first tile's latest |
There was a problem hiding this comment.
Nit (quality, left for the release quality pass): the doc says natively only the first tile's header is read before the layers are added. With found, probe is false (line 242), so the header is not read at all on the pane path. Say so here, or the doc will mislead the next reader.
There was a problem hiding this comment.
Right, thanks. Fixed in 2ad3136: the doc now says that without found only the first tile's latest header is read, and that with found (the pane, on the frame) none is.
Code reviewBugs
Security
Performance
Quality
AGENTS.md
Already raised earlier and fixed in this head, so not re-posted: the wasm Verified by reading, not by running: the Change sizeCompared with
|
opengeos#1720) embeddings.addCube's found dropped a file outside start..=end without a word: a caller passing a 2016 file with start 2017 lost it silently. The files left out are now counted and said. The pane's Cancel comment says why a search's bounded index read is left to run on. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| /// 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 { | ||
| let crs = |t: &Tile| t.detail.split(" · ").next().unwrap_or("").to_string(); |
There was a problem hiding this comment.
Nit (performance, confidence low-moderate). same_place runs for every index row against every tile seen so far (up to MAX_INDEX_ROWS = 4000 rows, so roughly rows × tiles calls), and each call allocates two Strings for the CRS prefix. Comparing borrowed &strs avoids that without changing behaviour:
| let crs = |t: &Tile| t.detail.split(" · ").next().unwrap_or("").to_string(); | |
| fn crs(t: &Tile) -> &str { | |
| t.detail.split(" · ").next().unwrap_or("") | |
| } |
There was a problem hiding this comment.
Taken in 2ad3136. same_place now compares borrowed &strs, and it checks the corners first, so most non-matching rows fail at the first float comparison and never reach the text. For scale: one read holds at most MAX_INDEX_ROWS = 4000 rows, and each tile has up to 9 yearly rows, so a full read is about 450 tiles. That makes about 4000 × 450 / 2 ≈ 0.9 M comparisons, and the theoretical worst case (every row a different tile) about 8 M. Each one is a few float subtractions, which is milliseconds on the search thread, off the frame. I didn't benchmark it separately.
| } | ||
| } | ||
| } | ||
| let name = match (many, emb::aef_place(&file)) { |
There was a problem hiding this comment.
Quality (confidence moderate). The place is appended to the layer name only when one call adds several tiles (many). The pane's **Add years' calls one tile at a time, so two tiles added from the pane get the same name, "AlphaEarth 2017–2025 (2, 17, 10)", and the layer list can't tell them apart. embeddings.add has the same pattern with the year. Suggest appending the place whenever aef_place gives one, or only when the name would clash with an existing tile layer. Not a data-loss issue, so this may wait for the release quality pass.
There was a problem hiding this comment.
Good catch. Fixed in 2ad3136: the place now goes in the layer name when the cube has several tiles, or when a tile layer with the same name is already on the map. So the pane's second Add years for another tile gives "AlphaEarth 2017–2025 (2, 17, 10) · 23S 0000008192-0000000000", and the first keeps its short name. alphaearth_years_on_the_time_slider covers two tiles added one at a time. I left embeddings.add (one year) as it is, since it's outside this PR.
Code reviewI found no bugs, security problems or AGENTS.md violations in the changed lines. Two low-stakes quality items are posted inline. I checked the three earlier concerns against the head code and they are addressed; I left all five prior threads alone. Bugs: none found. I traced the cube assembly and its undo path, the Security: none found. Performance
Quality
AGENTS.md: the new command has a doc comment explaining why it can wait, the Cancel comment is updated, the wasm gating is in place, the notices are counted, and the Prior threads (5): left alone. Three are fixed in the head: Change sizeCompared with main (merge base 41315de to PR head 7b4131f). The whole PR, by file size.
|
…pengeos#1720) - A cube whose name a tile layer already has takes its tile's place in it, so two tiles added from the pane one at a time can be told apart. - add_cube's doc says no header is read with found. - same_place compares borrowed CRS text, after the corners, with no allocation per comparison. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| let year_param = |k: &str, default: i32| match f(p, k) { | ||
| None => Ok(default), | ||
| Some(y) if y.fract() == 0.0 && y >= f64::from(first) && y <= f64::from(last) => Ok(y as i32), | ||
| Some(_) => Err(format!("{k} is a year from {first} to {last}")), | ||
| }; |
There was a problem hiding this comment.
Never silent, confidence high. f(p, k) returns None for any value that isn't a number or a numeric string. The None arm here then falls back to the default, so "start": "2020-01-01", "start": "twenty" or "start": true silently gives the full range 2017–2025 with no error and no notice. The caller asked for a narrower period and gets a different result. Only an absent or null key should take the default. Anything else present should be refused, as the out-of-range case already is.
| let year_param = |k: &str, default: i32| match f(p, k) { | |
| None => Ok(default), | |
| Some(y) if y.fract() == 0.0 && y >= f64::from(first) && y <= f64::from(last) => Ok(y as i32), | |
| Some(_) => Err(format!("{k} is a year from {first} to {last}")), | |
| }; | |
| let year_param = |k: &str, default: i32| match p.get(k).filter(|v| !v.is_null()) { | |
| None => Ok(default), | |
| Some(_) => match f(p, k) { | |
| Some(y) if y.fract() == 0.0 && y >= f64::from(first) && y <= f64::from(last) => Ok(y as i32), | |
| _ => Err(format!("{k} is a year from {first} to {last}")), | |
| }, | |
| }; |
A regression case belongs in the parameters test, e.g. json!({"dataset": "alphaearth", "start": "2020-01-01", "found": tile}), which should be an error.
There was a problem hiding this comment.
Confirmed and fixed in 8fe204e. Before the fix, "start": "2020-01-01" went through and the cube covered 2017–2025: the new parameters case failed on it. Now only a missing or null year takes the default, and anything else given is refused ("start is a year from 2017 to 2025"). "start": "2020-01-01" and "end": true are in parameters.
| let y: i32 = y.trim().parse().map_err(|_| bad())?; | ||
| let u = u.as_str().map(str::trim).filter(|u| u.starts_with("https://") && u.len() <= 4000).ok_or_else(bad)?; | ||
| if (start..=end).contains(&y) { | ||
| out.insert(y, u.to_string()); |
There was a problem hiding this comment.
Never silent, confidence moderate. Two keys that trim to the same year (for example "2017" and " 2017") both pass y.trim().parse(), and out.insert keeps only the last one. The other file is dropped without a notice or an error, which is the silent loss the rule forbids. The simplest fix is to refuse a tile whose years map repeats a year, using out.contains_key(&y) before the insert and returning bad(). A test would cover it.
There was a problem hiding this comment.
Confirmed and fixed in 8fe204e. A tile whose years names one year twice ("2017" and " 2017") used to be accepted, and one of the files was lost. The new parameters case failed on it. It is now refused, with "found: a tile names the year 2017 twice", and it is in parameters.
Code reviewI read the full diff and the staged AGENTS.md, then checked the cross-file assumptions it depends on. Those were the Bugs
Security
Performance
Quality
AGENTS.md
Prior threads
Confidence: the two inline findings are high and moderate. The footprint-tolerance note is low. Change sizeCompared with the base branch (merge base 41315de to PR head 2ad3136).
|
stac.rs: add_cube keeps calling cube_slider (main's slider block was unchanged), and main's range sampling (opengeos#1646) follows it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…pengeos#1720) - start or end given as something other than a year ("2020-01-01", true) took the default 2017-2025 without a word; only a missing or null one does now, anything else is refused. - A found tile naming one year twice (" 2017" and "2017") kept one of its files and lost the other silently; it is refused. Both reproduced in the parameters test first. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
|
||
| /// AlphaEarth's stretch a command names: dequantised values shown over ±stretch. | ||
| fn stretch(p: &Value) -> Result<f32, String> { | ||
| let stretch = f(p, "stretch").map(|v| v as f32).unwrap_or(emb::AEF_DEFAULT_STRETCH); |
There was a problem hiding this comment.
Never silent, confidence high. f(p, "stretch") returns None for any non-numeric value, so "stretch": "big" or "stretch": true silently falls back to the default 0.3 and the cube is built with a look the caller didn't ask for. addCube is the new caller, and it takes the same default. Only an absent or null key should take the default; anything else given should be refused, as the range check already does. This is the same class of problem the start/end thread fixed.
| let stretch = f(p, "stretch").map(|v| v as f32).unwrap_or(emb::AEF_DEFAULT_STRETCH); | |
| let stretch = match p.get("stretch").filter(|v| !v.is_null()) { None => emb::AEF_DEFAULT_STRETCH, Some(_) => f(p, "stretch").ok_or("stretch is a number above 0, at most 1").map(|v| v as f32)? }; |
A regression case belongs in the parameters test, e.g. json!({"dataset": "alphaearth", "stretch": "big", "found": tile}).
There was a problem hiding this comment.
Confirmed and fixed in 792df07. Before the fix, "stretch": "big" went through as 0.3: the new parameters case failed on it. Now only a missing or null stretch takes the default, and anything else given is refused ("stretch is a number above 0, at most 1"). stretch() is shared, so this covers embeddings.add as well as addCube. "big" and true are in parameters.
| for (y, u) in t.get("years").and_then(Value::as_object).ok_or_else(bad)? { | ||
| let y: i32 = y.trim().parse().map_err(|_| bad())?; | ||
| // Two keys of one year (" 2017" and "2017"): one file would be lost, so refused. | ||
| if seen.contains(&y) { |
There was a problem hiding this comment.
Performance, confidence low-moderate. seen is a Vec checked with contains for every key of a tile's years object, so the duplicate check is quadratic in the number of keys. The object is not bounded in size, so a crafted found with many thousands of keys in one tile costs seconds of CPU on the calling thread. A BTreeSet<i32> (or a BTreeMap of all parsed keys, checked with contains_key) makes it O(n log n) without changing behaviour. If a size cap on years is preferred, a count check against the years in range would also do.
There was a problem hiding this comment.
Taken in 792df07: the repeat check is now a BTreeSet<i32> (insert returns false for a year already seen), and the behaviour is unchanged.
Code reviewPR #1749 adds Bugs
Security
Performance
Quality
AGENTS.md
Already raised, left alone (9 prior threads): the wasm dead-code warning, the partial-read notice, the silent extra years, the duplicate-year refusal, the Cancel behaviour, the header-read doc, the Change sizeCompared with the base branch (merge base 33debef to PR head 8fe204e).
|
… gaps only for a cube added (opengeos#1720) - stretch given as something other than a number ("big", true) took the default 0.3 without a word, in embeddings.add and addCube; only a missing or null one does now. Reproduced in the parameters test first. - What a search over the years left out is said once the cube is added, not before a refusal that adds nothing. - A tile's years are checked for repeats with a set. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Thanks. Notes on the rest of this review (792df07, with main merged in 845f1ed, which includes #1751):
|
| /// reads one tile's; with `found` (the pane, on the frame) no header is read. The rest are read in | ||
| /// place as the map draws them. | ||
| /// | ||
| /// Without `found` the command runs on its caller's thread and waits there for the index and that |
There was a problem hiding this comment.
AGENTS.md (Never hold the window), confidence moderate. The justification here is that the assistant "ha[s] no frame to hold". But Session::run_leaving_downloads says the assistant's commands do wait, and the assistant runs inside the desktop app. So an in-app assistant call without found blocks the window for one index read plus a COG header (about 13 s in the PR's own check). A plugin's command in the app is refused for exactly this reason, but the assistant is not. Either refuse the waiting route in the app for the assistant too, or route the assistant to the pane as the ribbon does. The doc comment should match whichever is chosen.
There was a problem hiding this comment.
You're right that the in-app assistant runs on the frame, and the doc was wrong about it. The path is assistantpane::poll → with_agent → Agent::receive_result → Agent::run_tool → tools::run_with_budget → Session::run, on the UI thread with leave_downloads unset. So an assistant-run embeddings.addCube without found holds the window for the index read and one header.
The same is true today for every command that waits for the network when the assistant runs it: embeddings.search (search_aef_blocking), embeddings.add, layer.addUrl. Session::run_leaving_downloads documents it as deliberate ("the command line, scripts and the assistant run commands that wait, and see what was added"). There's no off-frame route for the assistant that this command could copy. Refusing only addCube for the assistant would leave the others as they are, and running the assistant's tools with leave_downloads or off the frame would change all of them. That belongs in its own issue, not in this PR.
71825ee corrects the doc comment and the PR text to say exactly this. I'm leaving this thread open for the maintainers' call on that wider change.
| ) | ||
| }); | ||
| }; | ||
| if dropped > 0 { |
There was a problem hiding this comment.
AGENTS.md (Never silent), confidence low-moderate. dropped (tiles past MAX_TILES) and duplicates (second files for a tile-year) are reported only on this pane info line. They never go through Session::notify, and the found path of embeddings.addCube never sees them, so series_notes is only reached by the command-line route. The info line is replaced by the next search. Consider emitting these as session notices from the pane's add (for example by passing the counts to the command), or confirm the info line is the intended report.
There was a problem hiding this comment.
In the pane the info line is the intended report, and it is visible where the decision is made. It is drawn right above the result cards (embeddingsui.rs, if let Some(i) = &st.info), from when the search lands until the next search, a dataset change or Cancel. Adding a tile doesn't clear it, so "AlphaEarth: N found · 12 more left out: zoom in for them" or "· 3 tile-years had a second file; the first was taken" sits above the tiles the user is picking from. Nothing is lost silently on the pane route: a tile past MAX_TILES isn't listed, so it can't be added, and a duplicate tile-year was resolved at search time, before found is built. That's the moment the line reports it.
series_notes reaches the session's notices only on the command route, because there is no pane there to show it. The pane's single-year search reports complete the same way, so this follows that pattern. If you'd rather have the counts as session notices too, I can pass them to the command.
Code reviewBugs
Security
Performance
Quality
AGENTS.md
Prior reviews: 11 earlier threads. All are either fixed in the code I read (wasm gating, notice wording, outside-range and duplicate-year refusals, stretch and start/end parsing, the BTreeSet change, the layer-name tagging, the header doc, the Change sizeCompared with
|
… frame (opengeos#1720) The doc said the assistant has no frame to hold. The app's assistant runs its tools on the frame without leave_downloads, as it does for embeddings.search and layer.addUrl, so addCube without found waits there too. The doc now says so. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| /// 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; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
Code reviewI read the full diff, the staged AGENTS.md, and the 13 prior review threads. Most of the diff is consistent with the repository rules. I found one new concern, posted inline. Everything else I checked is either already raised in a prior thread or does not hold up on reading. Bugs
Security
Performance
Quality
AGENTS.md
Prior threads: 13 in total. I left all 13 alone, because each is either answered and fixed or already raised and still open. Change sizeCompared: the whole PR against main (merge base a411d53 to PR head 71825ee).
|
Part of #1720: AlphaEarth's years on the time slider (
embeddings.addCube). Similarity and change (§3.2 of the issue) will come in a second PR, to keep this one small.What it does
Today
embeddings.searchandembeddings.add(#542) show one AlphaEarth tile for one year, and each year is a separate layer. This PR shows a tile's years as one layer on the time slider, one year per step. It reuses the mechanismstac.addCube(#559) builds on: a tile layer withtime_urls, one cloud-optimised GeoTIFF per date.embeddings.addCube{"dataset": "alphaearth", "bbox"?, "start"?, "end"?, "bands"?, "stretch"?, "name"?}adds one tile layer per AlphaEarth tile where the map is. Each layer reads its file for the year the slider is at. If the slider is off, it turns on over the years (unit: year,format: YYYY). If it's already on, it takes the years in, asstac.addCubedoes.crates/tiles/src/embeddings.rs):aef_series/search_aef_seriesuse the same single read of the GeoParquet index assearch_aef, since its rows already hold every year. Each year's export gives its files a new name prefix (for examplexdwdu82ch9yffuj03-0000008192-0000000000.tiffin 2017 andx1i7lpngfblt9wshv-0000008192-0000000000.tiffin 2024). So a tile's years are matched by place: the same CRS, and footprint corners that agree to 1e-4° (about 10 m, far below a tile's 82 km), so no rounding boundary splits a tile in two. A{YYYY}template can't describe the series.aef_tilesnow shares the row parsing (aef_rows) and behaves as before.embeddings.addCube(found). In the app nothing is read on the frame: the search runs off it, as before, and the layers are added without waiting for a header. The pane also gets a Cancel button while a search or read runs. It drops the result, and for a data read it releases the download.stac.rs: the slider part ofadd_cubemoved intocube_slider()with no behaviour change, and both cubes use it.map.setTiles): a new look (bands,range,dequantize), for example from the pane's Apply to layers, changed only the file shown. The slider brought the old look back at the next step. It now goes to every file intime_urlstoo, as it already did for atime_urltemplate. This also affectsstac.addCube's cubes.Never silent
complete).found) outside the years asked for, and tiles left with none, are left out, and the command says how many of each.MAX_TILES(60), and a second file for a tile-year (the first is kept) are each reported once, with a count: as notices from the command, and on the pane's info line.MAX_TILE_LAYERS= 32) is refused, asking for a smaller area, asstac.addCubedoes. A cube that fails partway removes the layers it added.Known limits and choices
embeddings.search/embeddings.addtoday, and Python holds the GIL meanwhile, as for any command. The app's assistant runs its tools on the frame, so there the window waits too, as it does today forembeddings.search,layer.addUrland the other commands that read the network; moving the assistant's tools off the frame would be a change for all of them. A plugin's command in the app (leave_downloads) is refused rather than waiting on the frame. The pane hands its own search over instead.found(the pane), the command works in the browser too. Without it, the search needs a blocking read, so it is refused there with a pointer to the pane.year_start_ms).cube.reduce,cube.trend) aren't offered for this cube. The useful analyses are cosine similarity and change between years, which come in the follow-up PR.Tests
rustgis-tiles:index_rows_to_series. It covers matching years by place (including a footprint a hair off across a 1e-5° rounding boundary), another place making another tile, years outside the range, a duplicate year counted, titles and details,year_start_ms(1970, 2017, 2024), andaef_place.rustgis-engine:parameters(13 refusedaddCubeinputs, with the map left unchanged) andalphaearth_years_on_the_time_slider. The second checks two tiles, files in the slider's order with the AlphaEarth look pluscube=1, slider dates andYYYY, the missing-years notice, a tile hidden in a year it has no file for while the other shows, no notice when every year is there, an incomplete read's wording, a tile with no year in range left out and said, and other bands holding at every year aftermap.setTiles. That last check fails without thetiles.rsfix. These run offline, throughfound.rustgis-cliagainst the real index at Viçosa (MG, Brazil):embeddings.addCubegave{"tiles": 2, "years": 9, "missing": 0}in about 13 s, andshare.exportMapat 2017 and 2025 gives different images.Local runs, Windows:
cargo fmt, andcargo clippy -p rustgis-tiles -p rustgis-engine -p rustgis-ui-egui --all-targets -- -D warningsclean.cargo test --lib: rustgis-tiles 428 passed; rustgis-ui-egui 789 passed (record_testsincluded); rustgis-engine 1225 passed, 1 failed:gdal_formats_open_when_gdal_is_installed, which fails on this PC because its GDAL is older than main needs (3.8), and is untouched here. After the first review: rustgis-tiles 428, rustgis-enginecmd::245, rustgis-ui-egui 789, all passed.cargo check --locked --target wasm32-unknown-unknown -p rustgis-webwithRUSTFLAGS=-D warnings(LLVM's clang for zstd-sys) passes.Screenshots
New UI (
ui_shot, World Atlas sample zoomed to Viçosa): All years, on the time slider and the tiles listed over 2017–2025 with Add years:After Add years, the time slider at 2017:
The same layer, slider at 2025:
No code was copied from other projects. AlphaEarth data: CC BY 4.0, "The AlphaEarth Foundations Satellite Embedding dataset is produced by Google and Google DeepMind."
🤖 Generated with Claude Code