egms-client is a small, typed, async Python library for the archive of EGMS, 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.
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 this library does
- Who is it for
- Quick start
- Where downloads are saved
- How it works
- Recipes
- The traps we already fell into
- API at a glance
- Contributing · Ideas to work on
- 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.
flowchart LR
S["🛰️ Sentinel-1<br/>radar, every few days"] --> I["InSAR processing<br/>(Copernicus)"]
I --> A["EGMS archive<br/>Ortho L3 tiles"]
A -->|egms-client| Y["📁 GeoTIFF on your disk<br/>speed in mm/year"]
Y --> U["Your analysis<br/>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.
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 or the ZIP). |
| 🛡️ | 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, 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
pip install egms-client # login, search, download
pip install "egms-client[raster]" # + mosaics (numpy, rasterio, pyproj)
Python 3.12 or newer.
2. Get a service key (once, 2 minutes)
- Go to land.copernicus.eu and sign in with EU Login.
- Open your profile → API Tokens → Create new token.
- 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:
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
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
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 csv --max-gb 10
--bbox is west,south,east,north in degrees. download stops before the
first byte if the total is above --max-gb (10 by default).
4. Make a mosaic and look at it
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:
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()
Where downloads are saved
In the directory you pass (directory in Python, --out on the command
line). If you pass none:
EGMS_DATA_DIR, if set;- 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:
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.
>>> 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.
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/<file>?id=<search id>
A-->>C: ZIP (~60 MB)
C-->>You: Path to the GeoTIFF (~4 MB)
Downloading: what fetch_tile decides
flowchart TD
start(["fetch_tile(tile, component, release, dir)"]) --> c1{"GeoTIFF already<br/>in dir?"}
c1 -- yes --> r1(["return its path<br/>no network"])
c1 -- no --> c2{"'…absent' marker<br/>in dir?"}
c2 -- yes --> r0(["return None<br/>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<br/>of the latest sub-release"]
d --> x["extract only the GeoTIFF<br/>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 the library leaves it out. (Need the time series? See Ideas to work on.)
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.
flowchart LR
subgraph yours["Your code"]
H["httpx.AsyncClient<br/>(timeouts, retries, proxy)"]
end
subgraph lib["egms_client"]
direction TB
CL["client.py<br/>EgmsArchive"]
AU["auth.py<br/>ServiceKey · token_request · parse_token"]
AR["archive.py<br/>search_body · parse_search · extract_tiff …"]
MO["mosaic.py<br/>mosaic · is_laea<br/>(extra: raster)"]
ER["errors.py<br/>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:EgmsArchivewires them to yourhttpx.AsyncClient. Timeouts, retries and proxies stay under your control.mosaic.py: optional, only needsnumpy,rasterio,pyproj.
Recipes
Download a whole region, both components
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
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",).
Always use the newest release
release = (await archive.releases())[-1] # sorted oldest first
Handle errors
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:
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 |
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=) |
Download them, skipping what is on disk. |
.search(tile, component, release) |
One search; None if the tile does not exist. |
.fetch_tile(tile, component, release, dir) |
GeoTIFF path (cached), or None. |
tiles_for_bounds(minx, miny, maxx, maxy) |
Tiles covering a rectangle (EPSG:3035 m). |
tile_id((e, n)) / parse_tile_id(text) |
(47, 19) ↔ "E47N19". |
default_directory() |
Where downloads go when no directory is given. |
mosaic(tiffs, minx=, miny=, maxx=, maxy=) |
One float32 array, NaN where no data. |
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.
Contributing
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.
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/.
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 (extra: raster)
│ └── errors.py # EgmsError
└── tests/
└── fixtures/ # real archive answers, for offline tests
How to propose a change:
flowchart LR
A["Open an issue<br/>or pick one"] --> B["Fork + branch"]
B --> C["Code + tests"]
C --> D["make check ✅"]
D --> E["Pull request<br/>Conventional Commits"]
E --> F["Review & merge 🎉"]
Rules are short: see CONTRIBUTING.md. Commits follow Conventional Commits, 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.
| Idea | Why it matters | Good for |
|---|---|---|
| 📓 Example notebooks (a city, a landslide, a coastline) | Show what the data can do | first contribution, GIS users |
📊 Progress bar for egms download |
Big downloads feel less like a black box | first contribution |
| 📈 Read the CSVs into pandas / GeoParquet (optional extra) | L2 points and L3 time series in one line | intermediate |
| 🧊 Export to xarray / Cloud-Optimized GeoTIFF | Plug into the scientific Python stack | intermediate |
| 🧭 Mosaics from L2 points (gridding, line-of-sight decomposition) | Your own maps from the raw measurements | advanced, InSAR users |
| 📚 Documentation site | Search, API reference, tutorials | 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. 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. Extracted from Limen.
If this project is useful to you, a ⭐ on GitHub helps others find it.
Metadata
Release files for egms-client 0.2.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| egms_client-0.2.0.tar.gz | 38.1 kB | With provenance | Details for egms_client-0.2.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| egms_client-0.2.0-py3-none-any.whl | Python 3 | none | any | Details for egms_client-0.2.0-py3-none-any.whl |
Total release size: 71.1 kB
Release files / egms_client-0.2.0.tar.gz
| Download URL | egms_client-0.2.0.tar.gz |
|---|---|
| Size | 38.1 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
4676c86787d4d798c131ebea387da8aac01366ee833a2b988f0d05a6ba1da375
|
|
BLAKE2b-256 checksum How to use checksums |
7e37d91657860ff2ba2109a3c83330b34fe2cbc2f1a5c85a7c547f64c29ec5ec
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 3, 2026.
Transparency logRelease files / egms_client-0.2.0-py3-none-any.whl
| Download URL | egms_client-0.2.0-py3-none-any.whl |
|---|---|
| Size | 33.0 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
aa464518e3a5c036edba2c2b13c7a2dcc8d162e826e90d3b3a45778e6713eabe
|
|
BLAKE2b-256 checksum How to use checksums |
4df9642d1043767714d2182edbc417025986281689eeece5287d1184f04728cc
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 3, 2026.
Transparency logSecurity for egms-client 0.2.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
All 2 files in this release have been uploaded with provenance metadata.
Attestation for egms_client-0.2.0.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 3, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.