# egms-client
> A small, typed, async Python client for the archive of EGMS, the Copernicus European Ground Motion Service: ground velocity across Europe from Sentinel-1 InSAR, in mm/year. It handles the CLMS service-key login, searches and downloads Ortho L3 tiles and L2A/L2B measurement points, and turns them into mosaics (Cloud-Optimized GeoTIFF), pandas/GeoParquet tables, xarray arrays and vertical/east-west maps.
Source: https://agentengineering.it/egms-client/
**egms-client** is a small, typed, async Python library for the archive of
[EGMS](https://egms.land.copernicus.eu/), the **European Ground Motion
Service**. EGMS measures how much the ground moves, millimetre by millimetre,
across all of Europe, using radar satellites. This library lets you download
that data from a script, with no browser and no copy-pasted links.
```python
path = await archive.fetch_tile((47, 19), "U", "2020-2025", Path("egms"))
# egms/EGMS_L3_E47N19_100km_U_2020_2025_1.tiff (vertical speed, mm/year)
```
---
## Contents
- [What is EGMS, in 30 seconds](#what-is-egms-in-30-seconds)
- [What this library does](#what-this-library-does)
- [Who is it for](#who-is-it-for)
- [Quick start](#quick-start)
- [Example notebooks](#example-notebooks)
- [Where downloads are saved](#where-downloads-are-saved)
- [Time series as tables](#time-series-as-tables)
- [How it works](#how-it-works)
- [Recipes](#recipes)
- [The traps we already fell into](#the-traps-we-already-fell-into)
- [API at a glance](#api-at-a-glance)
- [With an AI assistant](#with-an-ai-assistant)
- [Contributing](#contributing) · [Ideas to work on](#ideas-to-work-on)
- [Data licence and credits](#data-licence-and-credits)
---
## What is EGMS, in 30 seconds
The Sentinel-1 satellites fly over Europe every few days and send down radar
pulses. Comparing the echoes from one pass to the next (a technique called
**InSAR**) shows whether a point on the ground moved closer or farther, down
to a few millimetres. Copernicus turns years of these measurements into
**maps of speed**: how fast the ground goes up or down, east or west, in
**millimetres per year**.
```mermaid
flowchart LR
S["🛰️ Sentinel-1
radar, every few days"] --> I["InSAR processing
(Copernicus)"]
I --> A["EGMS archive
Ortho L3 tiles"]
A -->|egms-client| Y["📁 GeoTIFF on your disk
speed in mm/year"]
Y --> U["Your analysis
maps · models · alerts"]
```
What you can see with it, for example:
- a **town that is sinking** because groundwater is pumped out (subsidence);
- a **slope that creeps** downhill before a landslide;
- a **bridge, dam or railway** that settles more than it should;
- the ground **rising** above a volcano or a gas storage site.
> Negative vertical speed (`U < 0`) means the ground goes **down**;
> positive means it goes **up**.
Here is real data, made with one command of this library. Around Pozzuoli
the Campi Flegrei caldera is **rising by up to 16 cm a year**, while Naples
and Vesuvius barely move:
```bash
egms mosaic --bbox 13.95,40.75,14.55,41.0 --component U --out naples-U.tif
```
## What this library does
| | Feature | In plain words |
|---|---|---|
| 🔑 | **Login with a CLMS service key** | Your key file is all it needs, from a path or an environment variable. Tokens are created and renewed for you. |
| 🔎 | **Every product of the archive** | **L3** speed maps (vertical `U`, east-west `E`, 100 m grid) and **L2A / L2B** measurement points with their full time series. |
| 🗺️ | **Search by area in degrees** | Give a box in longitude/latitude; areas larger than the archive accepts are split for you. |
| 💾 | **Never download twice** | Files already on disk are skipped, interrupted runs resume, and you choose what to keep of each ZIP (`tiff`, `csv`, `xml`, `parquet` or the ZIP). |
| 📈 | **Time series as tables** | The CSVs in pandas, or straight to **GeoParquet** (a 250 MB CSV becomes ~60 MB) for GeoPandas, QGIS, DuckDB. |
| 🛡️ | **Safe by default** | Parallel downloads with a limit, automatic retries, a size check before the first byte. |
| 🖥️ | **Command line** | `egms search` and `egms download`, no Python needed. |
| 🧩 | **Mosaics** | Glue tiles into one array over any rectangle, with no resampling. |
| 🧪 | **Pure functions too** | Every step is also a plain function, if you have your own HTTP stack. |
## Who is it for
- **Researchers and students** in geology, geophysics and remote sensing who
want EGMS data in a notebook or pipeline.
- **GIS and data engineers** who need to download a region or a whole
country, repeatably.
- **Risk and infrastructure teams** (landslides, subsidence, monitoring of
roads, railways, dams) who use ground motion as one input among many.
It started inside [Limen](https://github.com/agent-engineering-studio/limen),
where the vertical and east-west speeds of each 1 km cell are features of a
landslide-risk model, and was extracted so that anyone can use it.
---
## Quick start
### 1. Install
```bash
pip install egms-client # login, search, download
pip install "egms-client[raster]" # + mosaics (numpy, rasterio, pyproj)
pip install "egms-client[tables]" # + CSVs as pandas / GeoParquet (pandas, pyarrow, pyproj)
pip install "egms-client[xarray]" # + maps and series as xarray DataArray
pip install "egms-client[mcp]" # + an MCP server for AI agents (egms mcp)
```
Python 3.12 or newer.
### 2. Get a service key (once, 2 minutes)
1. Go to [land.copernicus.eu](https://land.copernicus.eu/) and sign in with **EU Login**.
2. Open your profile → **API Tokens** → *Create new token*.
3. Save the JSON file you get, e.g. as `clms-service-key.json`.
It contains `client_id`, `user_id`, `private_key` and `token_uri`, and it
**does not expire**. Treat it like a password: keep it out of git.
> The key is shown **only once**, right after *Create new token*: copy the
> whole JSON then. The token list shows only its `key_id`, which is not
> enough to log in.
Then tell egms-client where it is, with **one** of these environment
variables, so no code ever contains a path or a secret:
```bash
export EGMS_SERVICE_KEY_FILE=~/clms-service-key.json # a file on disk
export EGMS_SERVICE_KEY="$(cat ~/clms-service-key.json)" # or the JSON itself (CI, Docker secrets)
```
### 3. Download your first tile
```python
import asyncio
from pathlib import Path
from egms_client import EgmsArchive
async def main() -> None:
async with EgmsArchive() as archive: # key from the environment
print(await archive.releases()) # ['2020-2024', '2020-2025']
path = await archive.fetch_tile((47, 19), "U", "2020-2025", Path("egms"))
print(path) # egms/EGMS_L3_E47N19_100km_U_2020_2025_1.tiff
asyncio.run(main())
```
That's it: `path` is a GeoTIFF of 1,000 × 1,000 pixels, each one the mean
vertical speed of a 100 m square, in mm/year.
### 3b. Or from the command line
```bash
egms releases
egms search --level L2B --bbox 14.4,40.0,15.7,41.0 # what is there, and how many GB
egms download --tile E47N19 --component U --keep tiff --out egms
egms download --level L2B --bbox 14.4,40.0,15.7,41.0 --keep parquet --max-gb 10
egms convert egms/EGMS_L3_E47N19_100km_U_2020_2025_1.csv # a CSV you already have
egms mosaic --bbox 13.95,40.75,14.55,41.0 --component U --out naples-U.tif # a map, extra raster
```
`--bbox` is *west,south,east,north* in degrees. `download` and `mosaic` stop
before the first byte if the total is above `--max-gb` (10 by default), and
show their progress: one updating line in a terminal, a line per file in a
log.
`egms mosaic` downloads the L3 tiles a box touches (once: they stay in
`--data-dir`), pastes them, and writes a **Cloud-Optimized GeoTIFF** in
EPSG:3035 (float32, NaN as nodata, with overviews): drag it into QGIS, or
serve it over HTTP as is.
### 4. Make a mosaic and look at it
```python
from egms_client import tiles_for_bounds
from egms_client.mosaic import mosaic
# A rectangle in EPSG:3035 metres (here: exactly tile E47N19)
bounds = dict(minx=4_700_000, miny=1_900_000, maxx=4_799_999, maxy=1_999_999)
async def download(archive: EgmsArchive) -> list[Path]:
tiffs = []
for tile in tiles_for_bounds(*bounds.values()):
path = await archive.fetch_tile(tile, "U", "2020-2025", Path("egms"))
if path is not None: # None: the tile is at sea or not covered
tiffs.append(path)
return tiffs
# ... after downloading:
vertical = mosaic(tiffs, **bounds)
print(vertical.values.shape, vertical.left, vertical.top)
# (1000, 1000) 4700000.0 2000000.0
```
Want a picture? With `pip install matplotlib`:
```python
import matplotlib.pyplot as plt
rows, cols = vertical.values.shape
extent = (
vertical.left,
vertical.left + cols * vertical.pixel,
vertical.top - rows * vertical.pixel,
vertical.top,
)
plt.imshow(vertical.values, cmap="RdBu_r", vmin=-10, vmax=10, extent=extent)
plt.colorbar(label="vertical speed (mm/year)") # blue: sinking, red: rising
plt.title("EGMS Ortho L3 · E47N19 · 2020-2025")
plt.show()
```
---
## Example notebooks
Three stories with real data, from download to figure, in [`notebooks/`](https://github.com/agent-engineering-studio/egms-client/tree/main/notebooks):
| | |
|---|---|
| [**Campi Flegrei**](https://github.com/agent-engineering-studio/egms-client/blob/main/notebooks/01_campi_flegrei.ipynb) | a caldera rising +79 cm in five years near Pozzuoli |
| [**A landslide in Ancona**](https://github.com/agent-engineering-studio/egms-client/blob/main/notebooks/02_landslide_ancona.ipynb) | measurement points on a moving slope, against still ones |
| [**Bologna sinking**](https://github.com/agent-engineering-studio/egms-client/blob/main/notebooks/03_sinking_bologna.ipynb) | how much of the plain subsides, and how fast |
## Where downloads are saved
In the directory you pass (`directory` in Python, `--out` on the command
line). If you pass none:
1. `EGMS_DATA_DIR`, if set;
2. otherwise `~/.cache/egms-client` (or `$XDG_CACHE_HOME/egms-client`).
The directory is also the cache: whatever is already there is **not
downloaded again**, so the next run with the same request only asks the
archive for the list of files (a few KB) and returns.
In **Docker**, point it at a volume so the data survives the container:
```yaml
services:
app:
environment:
EGMS_DATA_DIR: /data/egms
EGMS_SERVICE_KEY_FILE: /run/secrets/clms_service_key
volumes:
- egms-data:/data/egms
secrets:
- clms_service_key
volumes:
egms-data:
secrets:
clms_service_key:
file: ./clms-service-key.json
```
---
## How it works
### Tiles: how Europe is cut into squares
The Ortho L3 product lives on a single grid in **EPSG:3035** (the standard
equal-area projection for Europe). The grid is cut into 100 km squares, and
each square is named after its **lower-left corner, in hundreds of
kilometres**.
You never have to compute tile names by hand: give `tiles_for_bounds` a
rectangle and it returns every tile it touches.
```python
>>> tiles_for_bounds(4_530_000, 1_740_000, 4_670_000, 1_870_000)
[(45, 17), (45, 18), (46, 17), (46, 18)]
>>> tile_id((47, 19))
'E47N19'
```
### Logging in: the service key never leaves your machine
Your key signs a short-lived request (a JWT); the Copernicus server answers
with a token valid for one hour. `EgmsArchive` asks for a new one **five
minutes before it expires**, so a download of a whole country, which takes
longer than an hour, never stops halfway.
```mermaid
sequenceDiagram
autonumber
participant You as Your script
participant C as EgmsArchive
participant T as CLMS token endpoint
participant A as EGMS archive
You->>C: fetch_tile((47, 19), "U", "2020-2025")
C->>C: sign a JWT with the private key (RS256)
C->>T: POST token_uri (JWT bearer grant)
T-->>C: access_token, valid 1 hour
C->>A: POST /search (Bearer token)
A-->>C: search id + files
C->>A: GET /download/?id=
A-->>C: ZIP (~60 MB)
C-->>You: Path to the GeoTIFF (~4 MB)
```
### Downloading: what `fetch_tile` decides
```mermaid
flowchart TD
start(["fetch_tile(tile, component, release, dir)"]) --> c1{"GeoTIFF already
in dir?"}
c1 -- yes --> r1(["return its path
no network"])
c1 -- no --> c2{"'…absent' marker
in dir?"}
c2 -- yes --> r0(["return None
no network"])
c2 -- no --> s["search the archive"]
s --> c3{"tile exists?"}
c3 -- "no (sea, no coverage)" --> m["write '…absent' marker"] --> r0b(["return None"])
c3 -- yes --> d["download ZIP
of the latest sub-release"]
d --> x["extract only the GeoTIFF
write it atomically (.part → .tiff)"]
x --> r2(["return its path"])
```
Because **both** outcomes are written to disk, running the same script again
over a whole country sends **zero requests**. And because the file is first
written as `.part` and then renamed, an interrupted run never leaves a
half-written GeoTIFF behind.
### Why it keeps only the GeoTIFF
Each ZIP also holds a CSV with the full time series of every pixel. It is
large and most uses only need the mean speed, so `fetch_tile` leaves it out.
Need the time series? `fetch(..., keep=["parquet"])` keeps it as GeoParquet,
about a quarter of the CSV: see [Time series as tables](#time-series-as-tables).
### The building blocks
The library has two layers. You can use the high-level client, or call the
pure functions with any HTTP library you like.
```mermaid
flowchart LR
subgraph yours["Your code"]
H["httpx.AsyncClient
(timeouts, retries, proxy)"]
end
subgraph lib["egms_client"]
direction TB
CL["client.py
EgmsArchive"]
AU["auth.py
ServiceKey · token_request · parse_token"]
AR["archive.py
search_body · parse_search · extract_tiff …"]
MO["mosaic.py
mosaic · to_cog
(extra: raster)"]
ER["errors.py
EgmsError"]
end
H --> CL
CL --> AU
CL --> AR
AR -. GeoTIFFs .-> MO
```
- **`auth.py`, `archive.py`**: pure functions, no network. They build
requests and read answers. Easy to test, easy to reuse.
- **`client.py`**: `EgmsArchive` wires them to **your** `httpx.AsyncClient`.
Timeouts, retries and proxies stay under your control.
- **`mosaic.py`**: optional, only needs `numpy`, `rasterio`, `pyproj`.
---
## Recipes
### The speed at one point
```python
point = await archive.velocity_at(14.12, 40.83) # lon, lat in degrees: Pozzuoli
print(point.velocity, "mm/year") # vertical, latest release, the 100 m cell
print(point.file) # the L3 GeoTIFF it was read from
```
Only the point's tile is downloaded (once), so more points nearby cost
nothing. `component="E"` gives east-west. `velocity` is `None` where EGMS
has no measurement (sea, or a cell without data). Needs the `raster` extra.
### Without async
Scripts and notebooks that would rather not `await` can use
`egms_client.sync.Archive`. It has the same methods, which block until done,
and it works inside Jupyter too:
```python
from egms_client.sync import Archive
with Archive() as archive:
point = archive.velocity_at(14.12, 40.83)
tiff = archive.fetch_tile((47, 19), "U", "2020-2025", Path("egms"))
```
### Download a whole region, both components
```python
for component in ("U", "E"): # vertical, east-west
for tile in tiles_for_bounds(4_300_000, 1_500_000, 5_200_000, 2_700_000):
await archive.fetch_tile(tile, component, "2020-2025", Path("egms"))
```
Stop it and start it again whenever you like: finished tiles are skipped.
### Measurement points (L2A, L2B) over an area
```python
from egms_client import EgmsArchive, Query
query = Query(levels=("L2B",), release="2020-2025", bbox=(14.4, 40.0, 15.7, 41.0))
async with EgmsArchive() as archive:
found = await archive.find(query) # nothing downloaded yet
gb = sum(h.filesize for s in found for h in s.hits) / 1e9
print(f"{gb:.1f} GB") # 8.5 GB: 41 bursts
paths = await archive.fetch(query, Path("egms"), keep=["csv"], max_bytes=10**10)
```
Each CSV holds one row per measurement point: position (`latitude`,
`longitude`, `easting`, `northing`), quality (`temporal_coherence`,
`rmse_ts`), speed (`mean_velocity`, in mm/year along the satellite's line of
sight) and one column per acquisition date with the displacement in mm.
Other filters: `relative_orbit=124`, `burst_cycle=835`, `swath="IW3"`,
`direction="descending"`. L2A is the same with `levels=("L2A",)`.
### Time series as tables
With the `tables` extra, every CSV of the archive (L2A, L2B and L3) reads
the same way: attributes first, then one column per acquisition date with
the displacement in mm.
```python
from egms_client.tables import attributes, dates, read_csv, series_long
points = read_csv(Path("egms/EGMS_L3_E47N19_100km_U_2020_2025_1.csv"))
print(len(points), len(dates(points))) # 169917 cells, 302 dates
fast = attributes(points).query("mean_velocity < -10") # sinking > 1 cm/year
long = series_long(points[points.pid.isin(fast.pid)]) # pid, date, displacement_mm
```
`read_csv(path, series=False)` skips the dates and uses far less memory.
Or skip pandas and go straight to **GeoParquet**, points in EPSG:3035,
streamed in blocks so memory stays flat (~0.5 GB for a 250 MB CSV):
```python
paths = await archive.fetch(query, Path("egms"), keep=["parquet"]) # no CSV left on disk
```
```python
import geopandas as gpd
cells = gpd.read_parquet("egms/EGMS_L3_E47N19_100km_U_2020_2025_1.parquet")
cells.plot(column="mean_velocity", cmap="RdBu_r", vmin=-10, vmax=10, markersize=0.1)
```
DuckDB reads it too: `SELECT avg(mean_velocity) FROM 'egms/*.parquet'`.
### As xarray
With the `xarray` extra (`pip install "egms-client[raster,tables,xarray]"`),
maps and series become labelled arrays for the scientific Python stack:
```python
from egms_client.mosaic import to_xarray
from egms_client.tables import to_xarray as series_to_xarray
grid = to_xarray(vertical) # dims (y, x), EPSG:3035 in `spatial_ref`
grid.sel(x=4_773_750, y=1_900_050, method="nearest")
series = series_to_xarray(points) # dims (pid, time), displacement in mm
rising = series.where(series.mean_velocity > 10, drop=True)
rising.mean("pid").plot() # the average curve of the fastest-rising cells
```
### What the series say beyond their mean velocity
Every CSV comes with one model fitted to each series over the whole
release: `mean_velocity`, `acceleration`, `seasonality`, `rmse_ts`.
`egms_client.analysis` is that same model (over the full window it gives
back those columns), plus what they cannot show:
```python
from datetime import date
from egms_client.analysis import fit, trend_change, unwrapping_jumps
from egms_client.tables import read_csv
points = read_csv(Path("egms/EGMS_L2b_117_0256_IW3_VV_2020_2025_1.csv"))
now = fit(points, start=date(2024, 11, 1)) # velocity of the last year only, seasonal peak day
change = trend_change(points) # the date a series changes velocity, before / after
creeping = change[(change.delta_bic > 10) & (change.change < -5)] # started moving faster
jumps = unwrapping_jumps(points) # ±27.7 mm steps: processing errors, not ground motion
```
All three return one row per point with its `pid`. They run in blocks: a
whole L3 tile (170,000 series) takes about 3 seconds each. `delta_bic`
above 10 is strong evidence of a change of velocity. Read it as a ranking:
InSAR residuals are not independent, so it overstates certainty.
### Your own vertical and east-west maps from L2 points
L2 points move along each satellite's line of sight. Take one **ascending**
and one **descending** burst over the same place, average their points on a
grid, and two equations per cell give the vertical and east-west speed: what
L3 does, on the grid and the area you choose.
```python
import pandas as pd
from egms_client.decompose import decompose, grid_los
from egms_client.mosaic import lonlat_to_laea
bounds = dict(zip(("minx", "miny", "maxx", "maxy"), lonlat_to_laea(13.40, 43.55, 13.60, 43.66)))
ascending = grid_los(pd.read_parquet("egms/EGMS_L2b_117_0256_IW3_VV_2020_2025_1.parquet"), **bounds)
descending = grid_los(
pd.read_parquet("egms/EGMS_L2b_022_0816_IW2_VV_2020_2025_1.parquet"), **bounds
)
up, east = decompose(ascending, descending) # two Mosaics, mm/year: to_cog, to_xarray...
```
`grid_los(..., pixel=50, min_coherence=0.8)` picks the grid and how
strict to be. Checked against L3 over Ancona: correlation 0.93 on the
vertical, median difference 0.6 mm/year.
### Always use the newest release
```python
release = (await archive.releases())[-1] # sorted oldest first
```
### Handle errors
```python
from egms_client import EgmsError
try:
path = await archive.fetch_tile((47, 19), "U", "2020-2025", Path("egms"))
except EgmsError as exc:
# The archive or the token endpoint answered, but refused:
# bad key, expired search, missing GeoTIFF in the ZIP...
print("EGMS said no:", exc)
except httpx.HTTPError as exc:
# Network problems still failing after 3 retries.
print("network:", exc)
```
### Bring your own HTTP (sync example)
No `asyncio`? Every step is a plain function:
```python
import time
from pathlib import Path
import httpx
from egms_client import (
ARCHIVE_URL,
ServiceKey,
check_download,
download_url,
extract_tiff,
latest_hit,
parse_search,
parse_token,
search_body,
token_request,
)
key = ServiceKey.from_json(Path("clms-service-key.json").read_text())
with httpx.Client(timeout=120) as http:
now = time.time()
r = http.post(
key.token_uri, data=token_request(key, now=int(now)), headers={"Accept": "application/json"}
)
token = parse_token(r.status_code, r.content, now=now)
r = http.post(
f"{ARCHIVE_URL}/search",
headers=token.headers(),
json=search_body((47, 19), "U", "2020-2025"),
)
search = parse_search(r.content) # None: tile at sea
hit = latest_hit(search) if search else None
if search and hit:
r = http.get(download_url(ARCHIVE_URL, hit), params={"id": search.id})
tiff = extract_tiff(check_download(r.status_code, r.content), hit)
Path(f"{hit.stem}.tiff").write_bytes(tiff)
```
---
## The traps we already fell into
The EGMS Explorer is built for clicking. The API behind it works, but the
parts that matter are undocumented, and each one fails **quietly**. This
library handles all of these for you:
| You try | What actually happens | What egms-client does |
|---|---|---|
| any call without a login | HTTP **200** with `{"status": false, "message": "Please log in…"}` | raises `EgmsError` |
| the `id` in a download link as a credential | it is the id of a **search**, and it expires: HTTP 401 | searches, then downloads right away |
| a tile at sea | `status: false` and `Invalid tile ID`, not an empty result | returns `None`, remembers it |
| building the file name yourself | the trailing `_1` is a sub-release that can change | reads the name from the search hit |
| a malformed token request | HTTP **500** `DecodeError`, not 401: retrying won't help | raises `EgmsError` with the message |
| comparing CRS by name | four 2020-2025 tiles say `IGNF:ETRS89LAEA`, not `EPSG:3035` | compares projection parameters |
| expecting 2021-2025 after 2020-2024 | the next release was 2020-2025: windows grow | lists releases from the API |
| asking for level `L2a` as in the file names | `Invalid level`: the API wants `L2A` | uses the API's names |
| relative orbit `22` | `Invalid relative orbit`: it must be the string `"022"` | pads it for you |
| a bbox in any other shape than `[[w, s], [e, n]]` | `An error occurred processing the search` | builds the right shape |
| a box of 7° × 7° | `Requested area is too large` (5° × 5° passes) | splits it and merges the results |
| a third download at the same time | HTTP **429** `At most 2 downloads may be performed simultaneously` (per account) | never more than 2 at once, whatever you ask |
All checked against the live archive on 3 November 2026.
---
## API at a glance
| Name | What it does |
|---|---|
| `ServiceKey.from_env()` | The key from `EGMS_SERVICE_KEY` or `EGMS_SERVICE_KEY_FILE`. |
| `ServiceKey.from_file(path)` / `.from_json(text)` | The key from a file or a string. |
| `EgmsArchive(client=None, key=None)` | The async client; opens its own HTTP client and reads the key from the environment if not given. |
| `.releases()` / `.levels()` / `.bursts()` | What the archive publishes. |
| `Query(levels, release, bbox=, tile=, relative_orbit=, ...)` | What to look for. |
| `.find(query)` | The matching files, latest version of each; nothing downloaded. |
| `.fetch(query, dir, keep=, concurrency=, max_bytes=, on_progress=)` | Download them, skipping what is on disk; `on_plan`, `on_progress`, `on_file` report as it goes. |
| `.search(tile, component, release)` | One search; `None` if the tile does not exist. |
| `.fetch_tile(tile, component, release, dir)` | GeoTIFF path (cached), or `None`. |
| `.velocity_at(lon, lat, component=, release=, dir)` | The L3 velocity of the cell at a point, with its file (`PointVelocity`). |
| `egms_client.sync.Archive` | The same methods without `async`, for scripts and notebooks. |
| `egms mcp` / `mcp_server.create_server()` | EGMS as tools for AI agents (extra `mcp`). |
| `tiles_for_bounds(minx, miny, maxx, maxy)` | Tiles covering a rectangle (EPSG:3035 m). |
| `tile_for_point(x, y)` | The tile containing a point (EPSG:3035 m). |
| `tile_id((e, n))` / `parse_tile_id(text)` | `(47, 19)` ↔ `"E47N19"`. |
| `hit.covers(lon, lat)` / `hit.footprint` | Whether a file's real outline contains a point (bursts are tilted, their bbox is larger). |
| `default_directory()` | Where downloads go when no directory is given. |
| `mosaic(tiffs, minx=, miny=, maxx=, maxy=)` | One float32 array, NaN where no data. |
| `decompose.grid_los(points, minx=, ...)` / `decompose.decompose(asc, desc)` | L2 points on a grid; ascending + descending into vertical and east-west `Mosaic`s. |
| `to_cog(mosaic, path)` / `lonlat_to_laea(w, s, e, n)` | A mosaic as Cloud-Optimized GeoTIFF; a box in degrees as EPSG:3035 metres. |
| `tables.read_csv(path, series=, columns=)` | A CSV as a pandas DataFrame (extra `tables`). |
| `tables.dates(df)` / `.attributes(df)` / `.series_long(df)` | Its dates, its attributes, its series one row per date. |
| `tables.csv_to_geoparquet(csv)` / `.to_geoparquet(df, path)` | GeoParquet, points in EPSG:3035. |
| `mosaic.to_xarray(m)` / `tables.to_xarray(df)` | A map over `(y, x)`, series over `(pid, time)`, as `xarray.DataArray` (extra `xarray`). |
| `analysis.fit(df, start=, end=)` / `.trend_change(df)` / `.unwrapping_jumps(df)` | The archive's model on any window, with the seasonal peak; the date a velocity changes; ±27.7 mm processing steps. |
| `EgmsError` | The server answered, but refused. |
Lower level, for your own HTTP layer: `token_request` / `parse_token`,
`search_body` / `parse_search` / `latest_hit`, `parse_releases`,
`download_url` / `check_download` / `extract_tiff`.
---
## With an AI assistant
### As tools: an MCP server
`egms mcp` (extra `mcp`) serves EGMS as tools to any
[MCP](https://modelcontextprotocol.io/) client: Claude Desktop, Claude Code,
IDE assistants. Ask "is the ground moving under Pozzuoli?" and the answer
is computed from the data:
| Tool | What it returns |
|---|---|
| `releases` | the published releases |
| `find_files(west, south, east, north, level)` | the files over a box and their size; nothing downloaded |
| `velocity_at(lon, lat, component)` | the L3 velocity of the 100 m cell at a point |
| `area_summary(west, south, east, north, component)` | coverage, mean, percentiles, share sinking / rising, the 5 fastest cells |
Every answer names its release and files. The server's instructions tell
the model to quote numbers only as returned, and never to turn a velocity
into a safety verdict. `area_summary` downloads at most `--max-tiles` L3
tiles per box (9 by default, about 60 MB each, kept for the next call).
The key stays in the server's environment and never enters the
conversation.
In Claude Code:
```bash
claude mcp add egms -e EGMS_SERVICE_KEY_FILE=$HOME/clms-service-key.json \
-- uvx --from "egms-client[mcp]" egms mcp
```
In Claude Desktop (`claude_desktop_config.json`), or any client that
launches a command:
```json
{
"mcpServers": {
"egms": {
"command": "uvx",
"args": ["--from", "egms-client[mcp]", "egms", "mcp"],
"env": { "EGMS_SERVICE_KEY_FILE": "/path/to/clms-service-key.json" }
}
}
}
```
### As documentation: `llms.txt` and a skill
The site publishes the documentation in the forms AI assistants read:
- [`llms.txt`](https://agentengineering.it/egms-client/llms.txt): an index
of the site ([llmstxt.org](https://llmstxt.org/));
- [`llms-full.txt`](https://agentengineering.it/egms-client/llms-full.txt):
every page in one plain-text file, with the API reference rendered from
the docstrings;
- an **agent skill**,
[`skills/egms-client/SKILL.md`](https://github.com/agent-engineering-studio/egms-client/blob/main/skills/egms-client/SKILL.md):
how to use the library, and the rules of the archive a model gets wrong
when it writes EGMS code from memory.
To give the skill to Claude Code, copy it into your project (or into
`~/.claude/skills/` for every project):
```bash
mkdir -p .claude/skills/egms-client
curl -o .claude/skills/egms-client/SKILL.md \
https://agentengineering.it/egms-client/skills/egms-client/SKILL.md
```
All three are rebuilt from the sources with the site, and a test checks
that every name the skill uses exists in the library.
---
## Contributing
Full documentation, with the API reference: **https://agentengineering.it/egms-client/**
Contributions of every size are welcome, from a typo to a new feature. You
don't need to be an InSAR expert: most of the code is plain Python, HTTP and
files.
```bash
git clone https://github.com/agent-engineering-studio/egms-client
cd egms-client
uv sync # install everything, dev tools included
make check # ruff + mypy --strict + pytest, the same gate as CI
```
The tests **do not need a service key or network**: they run against
recorded answers of the real archive in [`tests/fixtures/`](https://github.com/agent-engineering-studio/egms-client/tree/main/tests/fixtures).
```text
egms-client/
├── src/egms_client/
│ ├── auth.py # service key → JWT → bearer token
│ ├── archive.py # tiles, search, download: pure functions
│ ├── client.py # EgmsArchive: async client, cache, token renewal
│ ├── mosaic.py # tiles → one array, Cloud-Optimized GeoTIFF (extra: raster)
│ ├── tables.py # CSVs → pandas, GeoParquet (extra: tables)
│ ├── cli.py # the `egms` command
│ └── errors.py # EgmsError
└── tests/
├── fixtures/ # real archive answers, for offline tests
└── live/ # the same behaviour, checked weekly on the real archive
```
The archive's API is undocumented, so a **weekly workflow**
(`.github/workflows/live.yml`) runs `tests/live` against the real archive
and opens an issue if something changed. To run it yourself:
`EGMS_LIVE=1 EGMS_SERVICE_KEY_FILE=~/clms-service-key.json uv run pytest tests/live`.
How to propose a change:
```mermaid
flowchart LR
A["Open an issue
or pick one"] --> B["Fork + branch"]
B --> C["Code + tests"]
C --> D["make check ✅"]
D --> E["Pull request
Conventional Commits"]
E --> F["Review & merge 🎉"]
```
Rules are short: see [CONTRIBUTING.md](https://github.com/agent-engineering-studio/egms-client/blob/main/CONTRIBUTING.md). Commits follow
[Conventional Commits](https://www.conventionalcommits.org/), coverage stays
above 90%, and contributions are accepted under Apache-2.0.
### Ideas to work on
Not promises, just directions where help would make a difference. Open an
issue to discuss one before starting.
The [roadmap](https://agentengineering.it/egms-client/roadmap/) goes further:
time-series analysis and AI features (an MCP server, `llms.txt`, reports
that cite their data).
| Idea | Why it matters | Good for |
|---|---|---|
| 📓 **More example notebooks** (a coastline, a dam, a mine) | Show what the data can do | first contribution, GIS users |
| 🧭 **North component and uncertainties** in `decompose` (a third geometry, `mean_velocity_std`) | Closer to L3, with error bars | advanced, InSAR users |
| 📚 **Tutorials on the documentation site** (a page per recipe, with figures) | Easier to follow than one long README | anyone |
Found something odd in the archive? That is a contribution too: open an issue
with what you sent and what came back.
---
## Data licence and credits
EGMS data is © European Union, Copernicus Land Monitoring Service, free and
open under the [Copernicus data policy](https://land.copernicus.eu/en/data-policy).
Anything you publish from it **must credit the source**, for example:
> Contains modified Copernicus Land Monitoring Service information (EGMS), © European Union.
The images in this README are illustrations, not EGMS data.
Code: [Apache-2.0](https://github.com/agent-engineering-studio/egms-client/blob/main/LICENSE). Extracted from
[Limen](https://github.com/agent-engineering-studio/limen).
If this project is useful to you, a ⭐ on GitHub helps others find it.
# Example notebooks
Three stories told with real EGMS data, from download to figure. Each one downloads only what it
needs (less than 350 MB), once: run it again and nothing is downloaded.
| Notebook | What it shows | Data | Download |
|---|---|---|---|
| [`01_campi_flegrei.ipynb`](https://github.com/agent-engineering-studio/egms-client/blob/main/notebooks/01_campi_flegrei.ipynb) | The caldera west of Naples rising by up to 16 cm a year, and +79 cm in five years near Pozzuoli | L3 map + one tile's time series (GeoParquet) | ~290 MB |
| [`02_landslide_ancona.ipynb`](https://github.com/agent-engineering-studio/egms-client/blob/main/notebooks/02_landslide_ancona.ipynb) | Measurement points around the Posatora landslide in Ancona, moving points against still ones | L2B, one ascending and one descending burst | ~170 MB |
| [`03_sinking_bologna.ipynb`](https://github.com/agent-engineering-studio/egms-client/blob/main/notebooks/03_sinking_bologna.ipynb) | Subsidence below Bologna and the plain: how much of the area sinks, and how fast | L3 map | ~270 MB |
## Run them
```bash
pip install "egms-client[raster,tables,xarray]" matplotlib jupyter
export EGMS_SERVICE_KEY_FILE=~/clms-service-key.json # see the main README
export EGMS_DATA_DIR=~/egms-data # where downloads are kept (optional)
jupyter lab
```
The notebooks are stored without outputs. Each one saves its figure in `docs/images/`.
*Data: EGMS, © European Union, Copernicus Land Monitoring Service.*
## `egms_client.client`
An async client over `egms_client.auth` and `egms_client.archive`.
Give it your `httpx.AsyncClient` and timeouts, proxies and pooling stay
yours; give it none and it opens (and closes) its own. Searches and downloads
are retried on network errors and on 429/502/503/504; other HTTP errors
propagate, and refusals the archive disguises as a 200 raise
`egms_client.errors.EgmsError`.
Downloads are streamed to a `.part` file and renamed when complete, so a
file that exists is whole, and memory stays flat however large the ZIP.
### `default_directory() -> Path`
Where downloads go when no directory is given.
`EGMS_DATA_DIR` if set, else `egms-client` in the user cache
(`$XDG_CACHE_HOME`, or `~/.cache`).
### `class PointVelocity(lon: float, lat: float, x: float, y: float, component: Component, release: str, tile: Tile, velocity: float | None, file: Path | None)`
The Ortho L3 velocity at one point, and where it comes from.
`velocity` is in mm/year (`U`: positive up; `E`: positive east), or
`None` where EGMS has no measurement: no tile there (sea, outside
coverage) or a cell without data. `file` is the GeoTIFF it was read
from (`None` without a tile); `x`, `y` the point in EPSG:3035.
### `class EgmsArchive(client: httpx.AsyncClient | None = None, key: ServiceKey | None = None, *, archive_url: str = ARCHIVE_URL, clock: Callable[[], float] = time.time, retries: int = 3, backoff_s: float = 2.0, max_downloads: int = MAX_DOWNLOADS, sleep: Callable[[float], Awaitable[Any]] = asyncio.sleep)`
Releases, searches and downloads, with a token renewed as needed.
`key` defaults to `ServiceKey.from_env`. Use it as an async
context manager, or call `aclose`, when it opened its own client.
#### `async EgmsArchive.aclose() -> None`
Close the HTTP client if this archive opened it.
#### `async EgmsArchive.token() -> Token`
A valid bearer token, asking for a new one when the current is about to expire.
Concurrent callers share one request. A busy endpoint (429/502/503/504,
or a network error) is retried; its 500 is a bad assertion, not retried.
#### `async EgmsArchive.releases() -> list[str]`
The published releases, oldest first (`["2020-2024", "2020-2025"]`).
#### `async EgmsArchive.levels() -> list[str]`
The product levels (`["L2A", "L2B", "L3"]`).
#### `async EgmsArchive.bursts() -> list[str]`
The burst cycles, as `Query.burst_cycle` takes them (`"0171"`).
#### `async EgmsArchive.search(tile: Tile, component: Component, release: str) -> Search | None`
Search one Ortho L3 tile; `None` if the tile does not exist.
#### `async EgmsArchive.find(query: Query) -> list[Search]`
Every file a query matches, the latest sub-release of each.
A `bbox` larger than the archive accepts is searched in pieces; a
file found by two pieces is listed once. Nothing is downloaded: sum
`hit.filesize` to know what `fetch` would cost.
#### `async EgmsArchive.fetch(query: Query, directory: Path | None = None, *, keep: Iterable[Keep] = ('zip',), concurrency: int = MAX_DOWNLOADS, max_bytes: int | None = None, on_file: Callable[[Hit, list[Path]], None] | None = None, on_progress: Progress | None = None, on_plan: Callable[[list[Hit]], None] | None = None) -> list[Path]`
Download what a query matches into `directory`; the paths kept.
`directory` defaults to `default_directory`.
Files already in `directory` are not downloaded again, so an
interrupted run resumes where it stopped. At most `MAX_DOWNLOADS`
files stream at once per archive, whatever `concurrency` says: a
higher one only overlaps extraction and conversion. `keep` picks what stays of
each ZIP. With `max_bytes` set, a download larger than that raises
`EgmsError` before any byte is fetched.
`on_plan(hits)` is called once with the files about to be downloaded;
`on_file(hit, paths)` when each is done;
`on_progress(hit, received, total)` as its bytes arrive (`received`
starts again from 0 if a download is retried).
#### `async EgmsArchive.fetch_tile(tile: Tile, component: Component, release: str, directory: Path | None = None) -> Path | None`
The tile's mean-velocity GeoTIFF in `directory`, downloaded if missing.
`directory` defaults to `default_directory`.
`None` when the tile does not exist. Both outcomes are remembered in
`directory` (the GeoTIFF, or an `…absent` marker), so running it
again for a whole country asks the archive nothing.
#### `async EgmsArchive.velocity_at(lon: float, lat: float, component: Component = 'U', release: str | None = None, directory: Path | None = None) -> PointVelocity`
The L3 velocity of the 100 m cell containing a point in degrees.
Only the point's tile is downloaded (about 60 MB, once: it stays in
`directory`, which defaults to `default_directory`), so the
next points of the same tile cost nothing. `release` defaults to
the latest. Needs the `raster` extra.
## `egms_client.sync`
The archive without `async`/`await`, for scripts and notebooks.
`Archive` has the methods of `egms_client.client.EgmsArchive`,
blocking until each one is done::
from egms_client.sync import Archive
with Archive() as archive:
print(archive.velocity_at(14.12, 40.83))
The async client runs on an event loop of its own, in a background thread.
That is why it also works inside Jupyter, where a loop is already running
and `asyncio.run` refuses to start another. The `on_*` callbacks of
`Archive.fetch` are called from that thread.
### `class Archive(client: httpx.AsyncClient | None = None, key: ServiceKey | None = None, **options: Any = {})`
`egms_client.client.EgmsArchive`, blocking.
Takes the same arguments. Use it as a context manager, or call
`close`: it stops the background thread.
#### `Archive.close() -> None`
Close the HTTP client (if the archive opened it) and stop the thread.
#### `Archive.releases() -> list[str]`
See `EgmsArchive.releases`.
#### `Archive.levels() -> list[str]`
See `EgmsArchive.levels`.
#### `Archive.bursts() -> list[str]`
See `EgmsArchive.bursts`.
#### `Archive.search(tile: Tile, component: Component, release: str) -> Search | None`
See `EgmsArchive.search`.
#### `Archive.find(query: Query) -> list[Search]`
See `EgmsArchive.find`.
#### `Archive.fetch(query: Query, directory: Path | None = None, *, keep: Iterable[Keep] = ('zip',), concurrency: int = MAX_DOWNLOADS, max_bytes: int | None = None, on_file: Callable[[Hit, list[Path]], None] | None = None, on_progress: Progress | None = None, on_plan: Callable[[list[Hit]], None] | None = None) -> list[Path]`
See `EgmsArchive.fetch`. The callbacks run on the background thread.
#### `Archive.fetch_tile(tile: Tile, component: Component, release: str, directory: Path | None = None) -> Path | None`
See `EgmsArchive.fetch_tile`.
#### `Archive.velocity_at(lon: float, lat: float, component: Component = 'U', release: str | None = None, directory: Path | None = None) -> PointVelocity`
See `EgmsArchive.velocity_at`.
## `egms_client.archive`
Searching and downloading from the EGMS archive: Ortho L3 tiles and L2 bursts.
The Ortho L3 product is the ground velocity resampled on a 100 m grid in
EPSG:3035 (LAEA Europe), in 100 km tiles, one per component: `U`
(vertical) and `E` (east-west). A tile is named by its lower-left corner
in hundreds of kilometres: E47N19 covers 4,700,000-4,800,000 m east and
1,900,000-2,000,000 m north. Each ZIP (~60 MB) holds a CSV of time series
(~245 MB once unpacked) and a GeoTIFF of the mean velocity (~4 MB).
Downloading needs the `id` of a **search**, not the bearer token: the
download URL carries `?id=`, and that id expires. An expired or
unknown id is answered with HTTP 401 and a plain-text message.
Verified against the live archive on 2026-11-03:
* releases are named `2020-2025` in the API and `2020_2025` in file
names; a release is a window of years, and a new one may extend the window
rather than slide it (2020-2024 was followed by 2020-2025, not 2021-2025);
* a tile at sea or outside coverage is answered with `status: false` and
`Invalid tile ID: 'E40N15'`, not with an empty search;
* each hit carries its own `filename` and `version` (`2020_2025_1`):
the trailing number is a sub-release, so read the name from the hit
instead of building it.
Beyond Ortho L3 the archive serves two levels of measurement points, one
ZIP per Sentinel-1 burst (a CSV of points with their time series and an XML
of metadata): L2A (`BASIC`) and L2B (`CALIBRATED`, tied to GNSS).
Verified on 2026-11-03:
* `GET /levels` names them `L2A`, `L2B`, `L3`; the lower-case
`L2a` of the file names is refused as `Invalid level`;
* a search needs a place: `bbox`, `tileId`, `swath`, `burstCycle`
or `relativeOrbit`, else `You must provide either bbox, ...`;
* `bbox` is `[[west, south], [east, north]]` in degrees, for every level;
any other shape is answered `An error occurred processing the search`,
and an area of 7° x 7° is `Requested area is too large` (5° x 5° passes);
* `relativeOrbit` is a string of three digits: `"022"` works, `22` and
`"22"` are `Invalid relative orbit`;
* one relative orbit is ~400 bursts and ~90 GB per level.
### `class Query(levels: tuple[Level, ...], release: str, bbox: LonLatBounds | None = None, tile: Tile | None = None, relative_orbit: int | str | None = None, burst_cycle: int | str | None = None, swath: str | None = None, direction: Direction | None = None, product_type: str | None = None)`
What to look for: levels, a release and at least one place.
`bbox` works for every level; `tile` is for L3; `relative_orbit`,
`burst_cycle` and `swath` are for L2A and L2B. `product_type`
narrows L3 to `ORTHO-UP` or `ORTHO-EAST`.
### `class Hit(filename: str, filesize: int, tile_id: str, release: str, version: str, product_type: str, level: str = '', path: str = '', bbox: LonLatBounds | None = None, burst_id: str = '', relative_orbit: str = '', burst_cycle: str = '', swath: str = '', direction: str = '', polarization: str = '', footprint: tuple[tuple[float, float], ...] = ())`
One downloadable file of a search. The L2 fields are empty for L3.
#### `Hit.covers(lon: float, lat: float) -> bool`
True if the point is inside the footprint (inside the bbox if there is none).
#### `Hit.stem: str`
The file name without `.zip`: also the name of every file inside.
#### `Hit.family: str`
The name up to the sub-release: hits of one family are versions of one file.
### `class Search(id: str, hits: list[Hit])`
A search id and its hits. The id is what the download URL needs.
### `tile_id(tile: Tile) -> str`
`(47, 19)` → `"E47N19"`.
### `parse_tile_id(text: str) -> Tile`
`"E47N19"` → `(47, 19)`; `ValueError` for anything else.
### `tile_for_point(x: float, y: float) -> Tile`
The tile containing a point in EPSG:3035 metres.
### `tiles_for_bounds(minx: float, miny: float, maxx: float, maxy: float) -> list[Tile]`
The tiles covering a rectangle in EPSG:3035 metres.
### `api_release(release: str) -> str`
`2020_2025` (file names) or `2020-2025` → `2020-2025` (the API's form).
### `file_release(release: str) -> str`
`2020-2025` or `2020_2025` → `2020_2025` (the file names' form).
### `tile_prefix(tile: Tile, component: Component, release: str) -> str`
The file name up to the sub-release: `EGMS_L3_E47N19_100km_U_2020_2025_`.
### `search_body(tile: Tile, component: Component, release: str) -> dict[str, Any]`
The JSON body of `POST {archive}/search` for one Ortho L3 tile.
### `query_body(query: Query) -> dict[str, Any]`
The JSON body of `POST {archive}/search` for a `Query`.
`ValueError` when the query has no place, which the archive would refuse.
### `split_bbox(bbox: LonLatBounds, step: float = MAX_BBOX_DEG) -> list[LonLatBounds]`
A box cut into pieces no larger than `step` degrees a side, west to east.
### `parse_releases(body: bytes | str) -> list[str]`
The release names, oldest first. `EgmsError` when not logged in.
### `parse_names(body: bytes | str, what: str) -> list[str]`
A JSON list of names (`/levels`, `/bursts`), in the archive's order.
### `parse_search(body: bytes | str) -> Search | None`
The search, or `None` when the tile does not exist (sea, no coverage).
Raises `EgmsError` for any other refusal, such as a missing login.
### `latest_hit(search: Search) -> Hit | None`
The hit with the highest sub-release, or `None` if there is none.
### `latest_hits(hits: Iterable[Hit]) -> list[Hit]`
One hit per file, the highest sub-release of each, in their first order.
### `download_url(archive_url: str, hit: Hit) -> str`
The URL to GET with `params={"id": search.id}`.
### `check_download(status_code: int, body: bytes) -> bytes`
The ZIP, or `EgmsError` (401 means the search id expired: search again).
### `extract_tiff(archive: bytes, hit: Hit) -> bytes`
The mean-velocity GeoTIFF from a tile's ZIP; the CSV is left inside.
### `extract(archive: Path, suffixes: Iterable[str], directory: Path) -> list[Path]`
The files `.` of a downloaded ZIP, written to `directory`.
Each is streamed to a `.part` file and renamed, so a file that exists is
whole. `EgmsError` if the ZIP lacks one of them.
## `egms_client.auth`
Access tokens from a CLMS service key.
The EGMS archive answers `{"status": false, "message": "Please log in to
perform archive API calls."}` to every anonymous call, releases included.
The way in that needs no browser is a **service key** of the Copernicus Land
Monitoring Service: land.copernicus.eu, EU Login, profile → "API Tokens" →
"Create new token". It is a JSON document with `client_id`, `user_id`,
`private_key` (RSA, PEM) and `token_uri`, and it does not expire.
The key signs a short JWT (RS256), which the token endpoint exchanges for a
bearer token valid one hour (OAuth 2.0 JWT bearer grant, RFC 7523).
A malformed assertion is answered with **HTTP 500** and a `DecodeError`,
not 401: a retry policy on 5xx will retry it pointlessly.
### `class ServiceKey(client_id: str, user_id: str, private_key: str, token_uri: str)`
A CLMS service key, as downloaded from the land.copernicus.eu profile.
#### `ServiceKey.from_json(text: str) -> ServiceKey`
Parse the JSON document; raise `ValueError` if a field is missing or empty.
#### `ServiceKey.from_file(path: str | os.PathLike[str]) -> ServiceKey`
Read the JSON file saved from the profile page (`~` is expanded).
#### `ServiceKey.from_env(environ: Mapping[str, str] | None = None) -> ServiceKey`
The key named by the environment, so no application hard-codes a path.
`EGMS_SERVICE_KEY` holds the JSON itself (for containers and CI
secrets); `EGMS_SERVICE_KEY_FILE` holds the path of the file. The
first one set wins; neither is `LookupError`.
### `class Token(access_token: str, expires_at: float)`
A bearer token and the Unix time it expires at.
#### `Token.headers() -> dict[str, str]`
### `assertion(key: ServiceKey, *, now: int, lifetime: int = ASSERTION_LIFETIME) -> str`
The RS256 JWT the service key signs to ask for a token.
### `token_request(key: ServiceKey, *, now: int) -> dict[str, str]`
The form body to POST to `key.token_uri`.
### `parse_token(status_code: int, body: bytes | str, *, now: float) -> Token`
The token from the endpoint's answer; `EgmsError` if it was refused.
## `egms_client.mosaic`
Ortho L3 tiles pasted onto one grid. Needs the `raster` extra.
The tiles sit on a common 100 m grid in EPSG:3035, so a mosaic is a copy of
each tile into its place, with no resampling.
Some tiles declare their CRS as `IGNF:ETRS89LAEA` instead of
`EPSG:3035`: the same projection under the French IGN's name. In the
2020-2025 release that is E43N26, E44N26, E45N26 and E46N26 (in 2020-2024,
E42N26 and E43N26). Comparing CRS by name rejects them; `is_laea`
compares the parameters.
### `class Mosaic(values: Any, left: float, top: float, pixel: float = PIXEL_M)`
A float32 array (NaN where there is no data) and its upper-left corner.
### `is_laea(crs: Any) -> bool`
True if a rasterio CRS is LAEA Europe (EPSG:3035) by its parameters.
### `mosaic(tiffs: Iterable[Path], *, minx: float, miny: float, maxx: float, maxy: float) -> Mosaic`
Paste the tiles of one component over a rectangle in EPSG:3035 metres.
The rectangle widens to the 100 m grid. Pixels no tile covers, and the
tiles' own nodata, are NaN. A tile in another projection raises
`ValueError`.
### `lonlat_to_laea(west: float, south: float, east: float, north: float) -> tuple[float, ...]`
A box in degrees as `(minx, miny, maxx, maxy)` in EPSG:3035 metres.
The edges are densified, so the result contains the whole box, not
only its corners: what `mosaic` and `tiles_for_bounds` take.
### `lonlat_to_xy(lon: float, lat: float) -> tuple[float, float]`
A point in degrees (WGS84) as `(x, y)` in EPSG:3035 metres.
### `value_at(tiff: Path, x: float, y: float) -> float | None`
The value of the pixel containing `(x, y)` (EPSG:3035 metres).
`None` where the tile has no data, or when the point is outside it. A
tile in another projection raises `ValueError`.
### `to_cog(m: Mosaic, path: Path) -> Path`
Write a mosaic as a Cloud-Optimized GeoTIFF in EPSG:3035.
float32, NaN as nodata, deflate-compressed with overviews: it opens in
QGIS or a browser map and streams over HTTP. Written to `.part` first.
### `to_xarray(m: Mosaic, *, name: str = 'velocity') -> Any`
A mosaic as an `xarray.DataArray` on `y` / `x` pixel centres.
The CRS travels CF-style in a `spatial_ref` coordinate, which rioxarray
and GDAL read; values are mm/year, NaN where no data. Needs `xarray`.
## `egms_client.tables`
The CSVs of the archive as tables. Needs the `tables` extra.
Every ZIP holds a CSV with one row per measurement point (L2A, L2B) or per
100 m cell (L3): its attributes, then one column per acquisition date
(`20251119`) with the displacement in millimetres since the first date.
* L2 columns start `pid, mp_type, latitude, longitude, easting, northing,
height_ortho, ...`; `mean_velocity` is along the satellite's line of
sight (`los_east`, `los_north`, `los_up` give its direction);
* L3 columns start `pid, easting, northing, height_ortho, ...`;
`mean_velocity` is vertical (`U`) or east-west (`E`).
`easting` / `northing` are EPSG:3035 metres in both, so
`to_geoparquet` writes one geometry for every level. Values are mm and
mm/year. An L3 CSV is ~250 MB for ~170,000 cells and ~300 dates.
### `read_csv(path: Path, *, series: bool = True, columns: Sequence[str] | None = None) -> Any`
A CSV of the archive as a `pandas.DataFrame`.
`series=False` skips the date columns (much less memory); `columns`
keeps only those attributes (`pid` is always kept). Dates are float32.
### `dates(frame: Any) -> list[date]`
The acquisition dates of a table, in its column order.
### `attributes(frame: Any) -> Any`
The table without its date columns.
### `series_long(frame: Any) -> Any`
One row per point and date: `pid`, `date`, `displacement_mm`.
### `to_xarray(frame: Any) -> Any`
The series of a table as an `xarray.DataArray` over `(pid, time)`.
Values are the displacement in mm (float32). Every attribute column
(`easting`, `mean_velocity`, ...) becomes a coordinate along `pid`,
so points can be selected by them. Needs `xarray`.
### `to_geoparquet(frame: Any, path: Path) -> Path`
Write a table as GeoParquet: points in EPSG:3035 from `easting` / `northing`.
Plain Parquet readers see the columns; GIS tools (GeoPandas, QGIS,
DuckDB spatial) also see the `geometry`. Written to `.part` first.
### `csv_to_geoparquet(csv: Path, parquet: Path | None = None, *, series: bool = True) -> Path`
A CSV of the archive straight to GeoParquet, next to it by default.
Streamed in blocks with pyarrow alone, so memory stays flat however large
the CSV: an L3 CSV of 250 MB becomes ~60 MB of Parquet. The file carries
no bbox (optional in GeoParquet), as it is not known before the end.
## `egms_client.decompose`
Maps from L2 points: gridding, and ascending + descending into vertical and east-west.
L2A / L2B points measure motion along the satellite's line of sight (LOS).
Each point carries the LOS unit vector, from the ground to the satellite:
`los_east`, `los_north`, `los_up` (descending, looking west: east
~ +0.65, up ~ +0.75; ascending, looking east: east ~ -0.69, up ~ +0.72). A
positive `mean_velocity` moves towards the satellite, so for a ground
motion `(E, N, U)`::
v_los = E * los_east + N * los_north + U * los_up
Both Sentinel-1 orbits fly almost north-south, so `los_north` is small
(~0.1) and north motion is left out, as EGMS does for L3. With one
ascending and one descending grid over the same cells, the two equations
give `U` and `E` cell by cell. This is what L3 does on its own 100 m
grid, here on any grid and any area.
Only numpy is needed; frames are anything indexable by column name
(a pandas DataFrame from `egms_client.tables.read_csv`, for example).
### `class LosGrid(velocity: Mosaic, east: Any, up: Any, count: Any)`
L2 points averaged per cell: LOS velocity, LOS direction, and how many points.
`velocity` is a `Mosaic` (mm/year, NaN where no point);
`east` and `up` are the mean LOS unit vector components per cell;
`count` the points per cell.
### `grid_los(frame: Any, *, minx: float, miny: float, maxx: float, maxy: float, pixel: float = 100.0, min_coherence: float = MIN_COHERENCE) -> LosGrid`
Average the points of one geometry onto a grid in EPSG:3035 metres.
The rectangle widens to the `pixel` grid, aligned like L3 when the
pixel is 100 m. Points outside it, or with `temporal_coherence` below
`min_coherence`, are left out.
### `decompose(ascending: LosGrid, descending: LosGrid) -> tuple[Mosaic, Mosaic]`
Vertical and east-west velocity (mm/year) from two LOS grids of the same cells.
NaN where either geometry has no point. `ValueError` if the grids are
not the same, or if both look the same way (two ascending grids cannot
be separated).
## `egms_client.analysis`
What the time series say beyond their mean velocity. Needs the `tables` extra.
Every EGMS CSV comes with one model fitted to each series over the whole
release: `mean_velocity`, `acceleration`, `seasonality` (the annual
amplitude) and `rmse_ts`. That model is::
displacement(t) = a + v t + acceleration / 2 * t**2 + A cos(2 pi t) + B sin(2 pi t)
with `t` in years from the middle of the window. `fit` is that same
model, and over the full window it gives back the archive's columns (the
tests check it on real CSVs). What it adds:
* any window: `fit(frame, start=date(2024, 11, 1))` is the velocity of the
last year, where the archive only gives five years at once;
* the seasonal **phase**: the day of the year the seasonal cycle peaks.
Two things the archive's columns cannot show:
* `trend_change`: a velocity that changes at one date, as on a slope
that starts to creep. One acceleration over five years averages it away.
* `unwrapping_jumps`: steps of half the radar wavelength (about
27.7 mm along the line of sight) between two acquisitions. They come from
the processing (phase unwrapping), not from the ground. Only meaningful on
L2A / L2B, whose series are along the line of sight.
Every function takes a table of `egms_client.tables.read_csv` (one
row per point, one column per date) and returns one row per point, in the
same order, with its `pid`. Points with a missing value in the window get
NaN. Points are processed in blocks, so a whole L3 tile (~170,000 cells)
fits in memory.
Least squares treats the residuals as independent, which InSAR residuals
are not: read the statistics as rankings, not as exact probabilities.
### `fit(frame: Any, *, start: date | None = None, end: date | None = None) -> Any`
The archive's model fitted to each series, over `start` to `end` (inclusive).
Columns: `pid`, `velocity` (mm/year), `acceleration` (mm/year²),
`seasonal_amplitude` (mm), `seasonal_peak_doy` (the day of the year
the seasonal cycle peaks, 1-366), `rmse` (mm), `dates` (how many
were used). Over the full window, `velocity`, `acceleration`,
`seasonal_amplitude` and `rmse` are the archive's `mean_velocity`,
`acceleration`, `seasonality` and `rmse_ts`.
The acceleration needs two years or more to mean something, and the
season needs one full year: shorter windows raise `ValueError`.
### `trend_change(frame: Any, *, min_segment_days: int = 365, step_days: int = 30, start: date | None = None, end: date | None = None) -> Any`
The date where each series changes velocity, if it does.
A continuous two-slope line (with the seasonal cycle) is fitted for
every candidate date, one every `step_days`, at least
`min_segment_days` from either end. The best one is kept.
Columns: `pid`, `breakpoint` (the date), `velocity_before` and
`velocity_after` (mm/year), `change` (after minus before),
`delta_bic` (how much better two slopes fit than one; above 10 is
strong evidence, and below 0 one slope is enough), `rmse` (mm).
### `unwrapping_jumps(frame: Any, *, tolerance_mm: float = 5.0, window: int = 5, start: date | None = None, end: date | None = None) -> Any`
Steps of a multiple of half a wavelength between two acquisitions.
A step counts when the difference between two consecutive dates is
within `tolerance_mm` of k times 27.7 mm (k ≥ 1), and the series stays
there: the median of the `window` dates after it, minus the median of
the `window` dates before, is the same step. A single outlier, which
goes up and comes back, is not a jump. Meant for L2A / L2B.
Columns: `pid`, `jumps` (how many), `first_jump` (its date, or
missing), `total_step_mm` (their sum, signed).
## `egms_client.cli`
`egms`: the archive from the command line.
egms releases
egms search --bbox 14.4,40.0,15.7,41.0 --level L2B
egms download --tile E47N19 --component U --keep tiff --out egms
egms convert egms/EGMS_L2b_124_0835_IW3_VV_2020_2025_1.csv
egms mosaic --bbox 13.9,40.7,14.6,41.0 --component U --out naples-U.tif
The key comes from `--key-file`, else from `EGMS_SERVICE_KEY` or
`EGMS_SERVICE_KEY_FILE`. Downloads go to `--out`, else to
`EGMS_DATA_DIR`, else to the user cache. A download larger than
`--max-gb` (10 by default) stops before the first byte.
### `main(argv: Sequence[str] | None = None, *, client: httpx.AsyncClient | None = None) -> int`
Run the command line; the exit status.
## `egms_client.mcp_server`
EGMS as tools for AI agents: an MCP server. Needs the `mcp` extra.
`egms mcp` serves these tools over stdio to any MCP client (Claude
Desktop, Claude Code, IDE assistants):
* `releases`: the published releases;
* `find_files`: what the archive holds over a box, and its size, with
nothing downloaded;
* `velocity_at`: the L3 velocity of the 100 m cell at a point;
* `area_summary`: statistics of the L3 velocity over a box, and its
fastest cells.
Every answer names the release and the files it was computed from. The
numbers come from the data, never from the model: the tools return them,
and the client only words them. Downloads are bounded: `area_summary`
refuses a box touching more than `max_tiles` L3 tiles (about 60 MB each,
kept in `directory` for the next call). The service key is read from the
server's environment (`EGMS_SERVICE_KEY_FILE` or `EGMS_SERVICE_KEY`) and
never passes through the conversation.
### `class FileInfo()`
### `class FilesFound()`
### `class Velocity()`
### `class Cell()`
### `class AreaSummary()`
### `create_server(archive: ArchiveFactory | None = None, *, directory: Path | None = None, max_tiles: int = MAX_TILES, key_file: Path | None = None) -> MCPServer[Any]`
The MCP server, its tools bound to one `EgmsArchive`.
`archive` builds that client on the first call (default: one reading
`key_file`, else the environment), so the server starts even without
a key and says what is missing when a tool is used. `directory` is
where tiles are kept (default `default_directory`).
### `summarize(tiffs: list[Path], *, box: tuple[float, float, float, float], stable_band: float = 2.0, fastest: int = 5) -> _Stats`
The statistics `area_summary` returns, over the cells inside a box in degrees.
Only cells whose centre falls inside the box count (the box is curved
in EPSG:3035, so its bounding rectangle holds more). Pure: no network.
### `run(**options: Any = {}) -> None`
Serve the tools over stdio, until the client disconnects.
## `egms_client.errors`
### `class EgmsError()`
The EGMS archive or the CLMS token endpoint answered, but not with what was asked.
The archive reports most failures as HTTP 200 with `{"status": false,
"message": ...}`, so a status check alone never sees them.
# Roadmap
Where egms-client could go next. Nothing here is a promise. The point is to
pick a direction together: open an issue before you start one of these.
Today the library **gets** EGMS data (search, download, mosaic, tables,
L2 → vertical / east-west). The next step is to help people **read** the
data, both people and AI agents. That work falls into three tracks, each
building on the one before:
```mermaid
flowchart LR
A["1 · Foundations
point queries, sync API,
uncertainties"] --> B["2 · Analysis
series that accelerate,
active areas, jumps"]
B --> C["3 · AI
MCP server, llms.txt,
grounded reports"]
```
!!! warning "Ground rule for every AI feature"
The numbers come from the data, computed by code that is tested. A
language model may choose *what* to compute and may explain it in
words. It never produces a velocity, a date or a point id itself. Every
answer says which file it came from, and none is presented as a hazard
warning.
---
## 1 · Foundations
These features are useful as they are, and the AI track needs them.
| Feature | What it is | Why | Effort |
|---|---|---|---|
| **Point query** ✅ | `await archive.velocity_at(lon, lat, component="U")`: the L3 value of the 100 m cell, fetching only its tile (cached) | "How fast does the ground move *here*?" is the first question every user asks. It is also the first tool an agent needs | S |
| **Sync façade** ✅ | `egms_client.sync.Archive` wraps `EgmsArchive` with `asyncio.run`, for scripts and notebooks that do not want `await` | Removes the first stumbling block for GIS users | S |
| **Uncertainties and north** in `decompose` | propagate `mean_velocity_std`; a third geometry for N | Error bars on every map (an idea already listed in the README) | M |
| **Release diff** | `egms diff --bbox ... 2019-2023 2020-2025`: the change in velocity between two releases | The windows overlap, and an area that speeds up between releases is news | M |
| **Streaming large CSVs** | `tables.iter_csv(path, chunksize=)` → GeoParquet without loading the whole file | L2 CSVs of a dense burst do not fit in memory comfortably | M |
| **STAC items** | a STAC Item for each downloaded file | Plugs into existing EO catalogues and tools | S |
| **Tutorials on the site** | a page per recipe, with figures | An idea already listed in the README | S |
---
## 2 · Analysis of the time series
EGMS L2 points come with a series of displacements, one every 6 to 12
days. A mean velocity hides the interesting cases. The methods below are
classic, explainable signal processing and machine learning, with no
language model involved. They would go in a new module,
`egms_client.analysis` (numpy, optionally scikit-learn).
| Feature | What it finds | Method | Effort |
|---|---|---|---|
| **Acceleration** ✅ | points whose motion speeds up (often the warning sign of a landslide) | `analysis.trend_change`: a two-slope line with its date, and `delta_bic` | M |
| **Seasonality** ✅ | the yearly cycle (clay soils, aquifers, thermal dilation of structures) | `analysis.fit`: amplitude and the day of the peak | S |
| **Unwrapping jumps** ✅ | steps of about ±28 mm (half the Sentinel-1 wavelength) that are processing errors, not ground motion | step detection on the differenced series | S |
| **Active deformation areas** | clusters of moving points, rather than isolated noisy ones | DBSCAN on position + velocity, above a stability threshold (the "ADA" approach in the InSAR literature) | M |
| **Anomalies** | points that behave unlike their neighbours | isolation forest on per-point features (velocity, acceleration, seasonal amplitude, coherence) | M |
| **Features for models** | per-cell features (velocity, acceleration, seasonality, share of moving points) on a regular grid | aggregation, the same as `grid_los` | S |
!!! note "What the archive already gives"
Each CSV already holds `acceleration` and `seasonality`: one quadratic
and one annual cycle fitted over the whole release.
[`analysis.fit`](api/analysis.md) is that same model, checked against
those columns, so it can run on any window. `trend_change` and
`unwrapping_jumps` are what the columns cannot show.
The last row is the one [Limen](https://github.com/agent-engineering-studio/limen)
needs for its landslide-risk model. Every method should be validated on
the three notebooks (Campi Flegrei, Ancona, Bologna), where we already know
what to expect.
---
## 3 · AI
### 3.1 An MCP server: EGMS for any agent
!!! success "Done: `releases`, `find_files`, `velocity_at`, `area_summary`"
`egms mcp`, extra `mcp`. See
[With an AI assistant](index.md#with-an-ai-assistant). `moving_points`
and `map` wait for track 2.
**What.** An optional extra, `egms-client[mcp]`, with an
[MCP](https://modelcontextprotocol.io/) server, `egms mcp`. It exposes the
library as tools for Claude, Claude Code, IDE assistants or any other MCP
client:
| Tool | Built on |
|---|---|
| `releases()` | `EgmsArchive.releases` |
| `find(place or bbox, level, release)` | `Query`, `find`; the file list and its size, with nothing downloaded |
| `velocity_at(lon, lat)` | point query (track 1) |
| `area_summary(bbox)` | mosaic + statistics: share of the area sinking, percentiles, hottest cells |
| `moving_points(bbox, min_speed)` | L2 + analysis (track 2) |
| `map(bbox, component)` | `to_cog` plus a PNG preview |
**Why.** Someone can ask "is the ground moving under the A14 near Ancona?"
and get an answer computed from the data, with no Python. It is also the
fastest way to spread the library.
**Guardrails.** Downloads stay within `max_bytes` (the tool asks before
going above it). The 2-download limit and the token handling stay in the
client, where they already are. The service key is read from the
environment and never passes through the conversation. Effort: **M**.
### 3.2 `llms.txt` and a skill for coding agents
!!! success "Done"
[`llms.txt`](https://agentengineering.it/egms-client/llms.txt),
[`llms-full.txt`](https://agentengineering.it/egms-client/llms-full.txt)
and the [agent skill](https://agentengineering.it/egms-client/skills/egms-client/SKILL.md)
are published with the site. See
[With an AI assistant](index.md#with-an-ai-assistant).
**What.** Publish `/llms.txt` and `/llms-full.txt` on this site: the API,
the recipes and the traps table, in plain text that AI assistants read.
Add an agent skill (`SKILL.md`) that tells a coding agent how to use the
library, and above all what not to do: the `_1` sub-release, the
`"022"` orbit, the 5° box, the 2 downloads.
**Why.** The traps table is the most valuable thing in this project, and
it is exactly what a model gets wrong when it writes EGMS code from memory.
Effort: **S** (generated from the docs in the `docs` workflow, so it never
goes stale).
### 3.3 Questions in plain language
**What.** `egms ask "vertical speed around Bologna, latest release"`. A
model turns the sentence into a `Query` through tool use (with a fixed
schema and no free code), the place name is geocoded by a deterministic
geocoder (Nominatim), the plan is shown with its size, and only then is
anything downloaded.
**Why.** It saves looking up tiles, orbits and boxes. **Optional and
pluggable**: the provider sits behind an interface, and the CLI works
exactly as today without it. Effort: **M**.
### 3.4 Area reports that cite their data
**What.** `egms report --bbox ...` produces a Markdown or HTML report:
maps, histograms, the fastest clusters (track 2), and a short text that
describes them. The text is generated from a JSON of computed figures, and
every number in it is checked against that JSON before the report is
written (a sentence with a number that is not there is removed).
**Why.** Municipalities and infrastructure teams need a readable summary,
and they need it to be right. Effort: **M**, after track 2.
### 3.5 Research directions
These are worth a notebook first, not a module:
- **Gap filling** on L3 with Gaussian processes (kriging), with an
uncertainty per cell.
- **Short-term forecast** of a point's series, to flag deviations from the
expected trend rather than to predict events.
- **Fusion** with rainfall (ERA5) or groundwater data, to separate seasonal
causes from structural ones.
---
## Suggested order
| # | Item | Track | Effort | Unlocks |
|---|---|---|---|---|
| 1 | ~~`llms.txt` + agent skill~~ ✅ | AI | S | better AI-written code, today |
| 2 | ~~Point query + sync façade~~ ✅ | 1 | S | the MCP server, beginners |
| 3 | ~~MCP server (`find`, `velocity_at`, `area_summary`)~~ ✅ | AI | M | agents over EGMS |
| 4 | ~~`analysis`: acceleration, seasonality, jumps~~ ✅ | 2 | M | moving points, reports, Limen |
| 5 | Active deformation areas + MCP `moving_points` | 2 · AI | M | the most useful question |
| 6 | Uncertainties in `decompose`, release diff | 1 | M | error bars, change |
| 7 | `egms ask`, `egms report` | AI | M | non-programmers |
*Sizes: S is a few days, M a week or two, both including tests and docs.*
# Contributing
Issues and pull requests are welcome.
- Run `make check` before pushing: ruff, `mypy --strict` and pytest must be
clean, the same gate CI runs.
- Commits follow [Conventional Commits](https://www.conventionalcommits.org/).
- Contributions are accepted under the project's license, Apache-2.0
(section 5 of the license: inbound = outbound). No separate CLA.
---
name: egms-client
description: Download and read Copernicus EGMS (European Ground Motion Service) InSAR data in Python with the egms-client library - Ortho L3 velocity tiles, L2A/L2B measurement points and their time series, mosaics, GeoParquet, vertical/east-west decomposition. Use when code needs ground motion, subsidence, uplift or landslide data over Europe, or mentions EGMS, egms-client or the EGMS archive.
---
# egms-client
`egms-client` is a typed, async Python client for the EGMS archive
(`https://egms.land.copernicus.eu/insar-api/archive`). Use it rather than
raw HTTP: the archive's API is undocumented, and it fails **quietly**
(HTTP 200 with `status: false`, expiring ids, a 429 on the third download).
The library already handles all of that.
Full documentation: https://agentengineering.it/egms-client/ ·
everything as plain text: https://agentengineering.it/egms-client/llms-full.txt
## Setup
```bash
pip install egms-client # search, download
pip install "egms-client[raster,tables,xarray]" # + mosaics, pandas/GeoParquet, xarray
export EGMS_SERVICE_KEY_FILE=~/clms-service-key.json # or EGMS_SERVICE_KEY=''
```
The service key is a CLMS JSON key (land.copernicus.eu → profile → API
Tokens). It is a secret. Never write its contents or its path into code,
logs or notebooks: let `EgmsArchive()` read it from the environment.
## The data, in one paragraph
- **Ortho L3** (`"L3"`): mean velocity on a 100 m grid in **EPSG:3035**, in
100 km tiles named by their lower-left corner in hundreds of km
(`(47, 19)` ↔ `"E47N19"`). One file per component: `"U"` vertical,
`"E"` east-west. Each ZIP is about 60 MB and holds a GeoTIFF (about 4 MB)
and a CSV of time series (about 245 MB).
- **L2A / L2B** (`"L2A"` basic, `"L2B"` calibrated on GNSS): measurement
points per Sentinel-1 burst. Their velocity is along the satellite's
**line of sight** (LOS), not vertical. The CSV has one column per
acquisition date (displacement in mm).
- Velocities are in **mm/year**. `U < 0` means the ground goes down. A
positive LOS `mean_velocity` moves towards the satellite.
- Releases are windows of years (`"2020-2024"`, `"2020-2025"`). Get them
from `await archive.releases()` (oldest first) and never guess the next
one.
## Recipes
```python
import asyncio
from pathlib import Path
from egms_client import EgmsArchive, Query, tiles_for_bounds
async def main() -> None:
async with EgmsArchive() as archive:
release = (await archive.releases())[-1]
# The vertical velocity at a point (lon, lat): one tile downloaded, once.
point = await archive.velocity_at(14.12, 40.83, "U", release, Path("egms"))
print(point.velocity, point.file) # mm/year (None at sea / no data), its source
# One L3 tile: path to the GeoTIFF (cached), or None if the tile does not exist.
tiff = await archive.fetch_tile((47, 19), "U", release, Path("egms"))
# L2B points over a box in degrees (west, south, east, north).
query = Query(levels=("L2B",), release=release, bbox=(14.4, 40.0, 15.7, 41.0))
found = await archive.find(query) # nothing downloaded
gb = sum(h.filesize for s in found for h in s.hits) / 1e9 # tell the user first
paths = await archive.fetch(query, Path("egms"), keep=["parquet"], max_bytes=10**10)
asyncio.run(main())
```
- Without `async` (scripts, Jupyter): `from egms_client.sync import Archive`.
It has the same methods, which block, and works as `with Archive() as archive:`.
Prefer it in notebooks over `asyncio.run`, which fails when a loop is already running.
- A map over a box in degrees: `from egms_client.mosaic import mosaic, lonlat_to_laea, to_cog`.
Convert the box with `lonlat_to_laea(w, s, e, n)`, pick tiles with
`tiles_for_bounds(minx, miny, maxx, maxy)`, fetch each with `fetch_tile`, then
call `mosaic(tiffs, minx=..., miny=..., maxx=..., maxy=...)`. NaN marks no data.
From a shell: `egms mosaic --bbox W,S,E,N --component U --out map.tif`.
- Tables: `from egms_client.tables import read_csv, attributes, dates, series_long`.
`read_csv(path, series=False)` reads the attributes only and is much lighter.
- Vertical and east-west from L2: `from egms_client.decompose import grid_los, decompose`.
Use one **ascending** and one **descending** grid over the same bounds.
- xarray: `egms_client.mosaic.to_xarray(m)` gives dims `(y, x)`, and
`egms_client.tables.to_xarray(df)` gives dims `(pid, time)`.
- Time series: `from egms_client.analysis import fit, trend_change, unwrapping_jumps`.
Do not refit what the CSV already has: `mean_velocity`, `acceleration`,
`seasonality` and `rmse_ts` are the archive's fit over the whole release.
Use `fit(table, start=...)` for a shorter window (the last year),
`trend_change` for the date a velocity changed, and `unwrapping_jumps` on L2
before trusting a fast point.
- A file's real outline: `hit.covers(lon, lat)`. A burst is a tilted
parallelogram, so its `bbox` claims points it does not contain.
## Rules (each one is a real failure of the archive)
1. **Never build file names or download URLs by hand.** The trailing `_1`
is a sub-release that changes. The `id` in a download link is a *search*
id that expires (HTTP 401). Use `find` / `fetch` / `fetch_tile`.
2. **Level names are upper case:** `"L2A"`, `"L2B"`, `"L3"`. `"L2a"` (as
in file names) is `Invalid level`.
3. **Relative orbits are three-digit strings** (`"022"`). The library pads
integers for you, so passing `22` to `Query` is fine. Raw API calls with
`22` fail.
4. **Boxes are at most 5° × 5° per search.** `find` / `fetch` split larger
ones. Do not split them yourself.
5. **At most 2 downloads at once per account.** A third is HTTP 429. One
`EgmsArchive` enforces this across all its calls, so share one instance
and do not create several to "go faster".
6. **A tile at sea is not an error:** `fetch_tile` returns `None` and
remembers it.
7. **Compare CRS by parameters, not by name.** Some tiles say
`IGNF:ETRS89LAEA` instead of `EPSG:3035`. `mosaic` already handles this.
8. **Downloads are big.** One relative orbit is about 400 bursts and about
90 GB per level. Always `find` first, report the size, and pass
`max_bytes` (CLI: `--max-gb`, 10 by default).
9. **Errors:** `EgmsError` means the server answered but refused. Its
message is the archive's message, so show it rather than retrying
blindly.
## Without writing code
If the user only needs answers (not code), the MCP server may already be
connected: tools `releases`, `find_files`, `velocity_at`, `area_summary`
from `egms mcp` (extra `mcp`). Prefer them to downloading data by hand.
## When reporting results
State the release, the level, the component and the file(s) a number came
from. Ground motion data is evidence, not a hazard assessment: do not
present a velocity as a safety verdict on a building or slope.
Data: EGMS, © European Union, Copernicus Land Monitoring Service.