# 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.