aicesat
# aicesat — cross-mission altimetry
<table>
<tr>
<td width="33%" valign="top"><img src="images/aicesat-explorer.png" alt="Explore: 3-D globe" width="100%"><br><sub><b>Explore</b> — a 3-D globe: navigate anywhere, draw a box or polygon, check coverage, and build a scene.</sub></td>
<td width="33%" valign="top"><img src="images/aicesat-scene.png" alt="Scene: co-registration viewer" width="100%"><br><sub><b>Scene</b> — ICESat-2 (blue) and GLAS (orange) draped on ArcticDEM; toggle plate motion and read the Δh panels.</sub></td>
<td width="33%" valign="top"><img src="images/aicesat-lake.png" alt="Lake: H3 grid" width="100%"><br><sub><b>Lake</b> — the persistent Parquet lake as an H3 grid: per-cell stats, a storage limit, and background loading.</sub></td>
</tr>
</table>
An MCP server for Claude Desktop that pulls real ICESat-2 **ATL03** photons and ICESat/GLAS **GLAH06** shots over a
chosen area, renders them as a 3D point cloud (deck.gl), and — on a toggle — applies an ITRF2014 + epoch
co-registration (plate motion, ITRF2014-PMM / NOAM) with pyproj, updating the co-located Δh statistics. Started from
[`docs/cross-mission-altimetry-mcp-spec.md`](docs/cross-mission-altimetry-mcp-spec.md), which carries the full design and rationale.
The co-registration removes **plate motion** between epochs. It does **not** remove ice flow, GIA, geoid/tide, firn
compaction, or the vertical datum, and the widget says so on every answer. GLAS heights are converted
TOPEX/Poseidon → WGS84 ellipsoid using the product's `d_deltaEllip`, with the saturation correction `d_satElevCorr`
applied; that is recorded in every comparability block.
## Setup
```bash
uv sync # Python 3.13
uv run pytest # offline unit tests
```
Earthdata Login: **fetching NASA data needs a token; browsing the existing lake does not.** The server resolves a
token in this order: `EARTHDATA_TOKEN` (env) → a token file at `~/.edl/token.prod` (override the path with
`AICESAT_EDL_FILE`) → `~/.netrc`. Generate a token at <https://urs.earthdata.nasa.gov> (User Profile → Generate
Token). The server never writes to stdout (stdio MCP transport); logs go to stderr.
## Claude Desktop
Add to `~/Library/Application Support/Claude/claude_desktop_config.json`, using the absolute path to your checkout.
**Put your Earthdata token in the `env` block** — this is machine-independent (no token file needed) and the
recommended way for a new user to configure the server:
```json
{
"mcpServers": {
"aicesat": {
"command": "/opt/homebrew/bin/uv",
"args": ["--directory", "/ABSOLUTE/PATH/TO/aicesat", "run", "aicesat-server"],
"env": { "EARTHDATA_TOKEN": "<your Earthdata Login token>" }
}
}
}
```
If you keep your token in a file instead, drop the `EARTHDATA_TOKEN` line and either place it at `~/.edl/token.prod`
or add `"AICESAT_EDL_FILE": "/path/to/your/token"` to `env`. Restart Claude Desktop. The unified UI renders inline as an MCP App; the server also serves it at
`http://127.0.0.1:8765/` (port via `AICESAT_PORT`) for use in a browser.
### Getting an Earthdata Login token (step by step)
1. **Create a free Earthdata Login account** if you don't have one: <https://urs.earthdata.nasa.gov> → *Register*.
2. **Sign in**, then open your profile (top-right) → the **Generate Token** tab → **Generate Token**. Copy the long
token string (a JWT that starts with `eyJ…`). Tokens last ~60 days — regenerate when one stops working.
3. **Paste it into the Claude Desktop config** shown above, as the value of `EARTHDATA_TOKEN` in the server's `env`
block. The config file is at `~/Library/Application Support/Claude/claude_desktop_config.json` (in Claude Desktop:
**Settings → Developer → Edit Config**).
4. **Restart Claude Desktop.** The Lake view works without a token; building a scene / checking coverage will now
authenticate. If it can't, the UI shows an error banner naming what to fix.
A `~/.netrc` with your Earthdata username/password also works, but a token in `env` is the most reliable and is what
we recommend for sharing the server with a colleague.
Tools: `open_ui`, `list_regions`, `list_scenes`, `check_coverage`, `show_photons` (region, bbox, or polygon),
`add_glas`, `coregister`, `find_timeseries_candidates`, `show_timeseries`, `lake_status`, `lake_load_cells`,
`job_status`.
### Asking for a time series
`find_timeseries_candidates` ranks the places in an already-built scene where an elevation time series can actually
be measured, and `show_timeseries` shows one of them. Both open the **Time series** view inline.
The search bins every mission's points into H3 cells and fixed time windows, keeps cells seen in three or more
windows, and fits one local reference plane per cell so surface slope is removed rather than mistaken for change.
Each cell gets a deterministic 0–1 confidence dominated by within-cell roughness — the real failure mode, where two
missions sample different sub-cell relief and fake a trend. `why` names the top two limiters in plain language.
The candidate list is summarised for the caller (no per-cell series, no geometry) and reports `n_candidates_total`
alongside what it returned, so a truncated answer still says how much it left behind; the view shows every cell.
`trend_cm_yr` is the least-squares rate through the window medians and is **uncorrected** for inter-campaign /
inter-sensor bias and for GIA — quote it with that caveat, as `params.notes` states on every answer.
## UI
One self-contained page (built from `src/aicesat/ui/*` + vendored deck.gl / h3-js by `scripts/build_ui.py`, pure
Python; the server rebuilds it on start when sources change), served both inline in Claude Desktop and at `/`:
- **Explore** — a 3-D globe (Natural Earth basemap, no flat projection). Navigate anywhere, draw a box or polygon,
check coverage, and build a scene as a background job; scene footprints and the loaded H3 cells are shown on the globe.
- **Lake** — the persistent Parquet lake as an H3 grid: per-cell stats on hover, a storage limit that auto-evicts the
least-recently-used cells, and background loading or eviction of selected cells.
- **Scene** — the 3-D viewer: ICESat-2 and GLAS points draped on a DEM, an **Adjustments** panel of correction toggles,
and the co-located Δh histograms. The true plate-motion shift is sub-pixel at scene scale, so the clouds do not
visibly move — the effect is read from the Δh panel, not an exaggerated visual. Panels collapse and close.
- **Time series** (`#ts/<scene_id>`) — the same candidate list and chart the Scene panel carries, given the whole
page and no 3-D. It needs only the scene *metadata* and one search call, so unlike the Scene view it renders
completely inside Claude Desktop, where the point cloud's push stream cannot follow (`tools/call` is
request/response). Selecting a different cell is a local redraw: the view already holds every candidate's series.
The UI talks to a transport-neutral API (`api.py`) exposed two ways: the localhost `/api/*` routes for the browser,
and `visibility:["app"]` MCP tools the host proxies for the inline app. `scripts/e2e_apps.py` checks the MCP-App wiring.
## Architecture
The ATL03 path does not open HDF5 at query time. Per granule, an index build (`index.py`) records each chunk's byte
range, filter pipeline, and the H3 cells it touches. Queries resolve a bbox/polygon to cells, fetch only the needed
chunks by HTTPS range request (EDL bearer token → presigned URL), decode them without an HDF5 library, and materialize
photons into a hive-partitioned Parquet lake (`lake.py`) with per-row provenance and co-registered coordinates. DuckDB
answers over the lake (`api.py`); a coverage table records what is materialized so repeat queries fetch nothing. The
earlier `earthaccess.open + h5py` path is kept as `atl03.extract_legacy` for comparison. `scripts/bench_access.py`
compares the access methods; see the spec (Appendix C) for the approach.
Scene geometry is latitude-aware (`scene.frame_crs`): polar-stereographic near the poles, a per-scene
azimuthal-equidistant projection elsewhere, so scenes render anywhere on Earth.
## DEM and imagery
The scene surface is a real DEM chosen by region, always on **WGS84-ellipsoid heights** (the same vertical reference as
ATL03), read by window over `/vsicurl/` and cached: **ArcticDEM v4.1** (Arctic, EPSG:3413) and **REMA v2.0** (Antarctic,
EPSG:3031) — both already ellipsoidal — and **Copernicus GLO-30** elsewhere, whose EGM2008 orthometric heights are
converted to ellipsoidal by adding the NGA egm08 geoid undulation (`h = H + N`). There is no photon-interpolated
fallback: a scene shows a DEM only where one covers it. Scene imagery is **Sentinel-2 cloudless** (EOX, CC BY-NC-SA 4.0),
draped on the surface. The Explore/Lake globe basemap is Natural Earth 50 m land (public domain). Attributions appear
on screen.
## Scripts
```bash
uv run scripts/check_coverage.py --region egig_west_flank # granule counts by month / laser campaign
uv run scripts/ingest.py egig_west_flank # index + byte-range ingest + lake query
uv run scripts/build_index.py --region egig_west_flank # offline index pre-build (amortized off the query path)
uv run scripts/make_scene.py egig_west_flank --glas --coreg # full pipeline for a region
uv run scripts/serve.py # widget server only, for local testing
uv run scripts/bench_access.py # access-method comparison
```
Data (`data/`) is gitignored; delete it to force a re-fetch.
## How Δh is measured
For each GLAS shot, the ICESat-2 surface height at the footprint centre comes from a local along-track linear fit of
the signal photons within the co-location radius. A disc median is an order statistic and cannot resolve the sub-cm
slope effect; the fit is continuous in position, so it can. Only the along-beam component of the plate-motion shift is
observable on a single beam, and the per-pair artifact panel keeps the mm-level vertical part of the frame step
separate from the slope effect. Every answer carries the `unresolved` list and states "plate motion applied", never
"the missions agree".
TDQS
Scored across 24 tools
The substantive tools are distinct, but the large block of ui_* tools have empty descriptions and overlapping names such as ui_job vs ui_jobs, ui_coverage vs check_coverage, and ui_lake_cells vs ui_lake_summary. An agent cannot reliably distinguish UI shortcuts from actual data operations, so several tool boundaries are unclear.
All names use lower_snake_case and are readable, but conventions vary between verb-first names like show_photons and check_coverage, noun-style names like lake_status and job_status, and the large ui_* prefix group. The ui_* group is internally consistent but the overall set lacks a single predictable naming pattern.
With 24 tools, the server sits at the heavy end of the acceptable range. Many ui_* tools are simple navigation shortcuts that could plausibly be consolidated, while the real operational core is closer to 10 tools, making the overall surface feel larger than its actual scope.
The core scientific workflow is covered: coverage checking, scene creation, adding GLAS data, co-registration, scene listing, lake loading, and job status. However, there are notable gaps such as no scene deletion or update, no job listing/cancellation, and no programmatic lake eviction, with the ui_* links only partially compensating for those missing operations.