Skip to content

omnilab/cds

Omnilab CDS: the Copernicus Climate Data Store - its datasets (ERA5, ERA5-Land, seasonal forecasts, climate projections, satellite and in-situ observations) as products over a place and a period, its catalogue's search, a dataset's variables and request form, what a request would cost, read without an account; with an account's key, a request submitted and followed until its file (NetCDF, GRIB, zip) is the product's asset to download. The client of ECMWF's data stores, which omnilab/cams uses for the Atmosphere Data Store.

Floor
Provider · Omnilab
Version
1.x
Conformance suite
not wired
Verified for real
Its README does not say
Licence
MIT
composer require omnilab/cds:1.x-dev

The Copernicus Climate Data Store (https://cds.climate.copernicus.eu/api/: its catalogue catalogue/v1, STAC; its requests retrieve/v1, OGC API - Processes) for omnisense/omnilab: ERA5, ERA5-Land, the seasonal forecasts, the climate projections and the store's other datasets as products and datasets, what a request would cost; with an account's key, the requests that make their files, followed as Jobs. One client for ECMWF's data stores: omnilab/cams builds it on the Atmosphere Data Store.

Installation

composer require omnilab/cds
use Omnilab\Cds\CdsSourceFactory;

$cds = (new CdsSourceFactory($httpClient))->create([
    'key' => getenv('CDS_KEY'),        // optional: an account's personal access token, for requests
    'mailto' => 'support@glitchr.io',  // optional: in the User-Agent
]);

With the Symfony bundle, the factory is registered as cds when the bundle knows it; otherwise register CdsSourceFactory as a service (it is autoconfigured as a SourceFactoryInterface).

Credentials

None to read the catalogue (products(), datasets(), dataset(), cost()). A request for a file (submit(), job(), jobs()) needs the personal access token of a Climate Data Store account, from its profile page on cds.climate.copernicus.eu, given as key and sent as PRIVATE-TOKEN; the account accepts each dataset's licence once on the store's site before its first request (a 403 before). The key is read from the configuration, never written.

Options

The factory's options, with the defaults it applies (CdsSourceFactory):

Option Type Default Required Effect
key ?string null no a CDS account's personal access token: submit(), job(), jobs() need it (refused without it, before any call); open() sends it when set
mailto ?string null no a contact address, sent in the User-Agent
user_agent ?string null no the whole User-Agent, in place of the one made from mailto
throttle float 0 no seconds left between two calls: the store queues requests and limits their cost, it publishes no rate
base_uri string https://cds.climate.copernicus.eu/api/ no the store's API, ending in /api/: another ECMWF data store by its /api/ address (the Atmosphere Data Store is omnilab/cams's)

Capabilities

capabilities() answers products, area, datasets, jobs, open; an operation it does not answer throws NotSupportedException before any call.

Capability Supported
entity no a dataset is a Product, not an entity; ERA5 as series at a point is omnilab/open-meteo's
search no
series no
products yes the datasets that cover the place and overlap the period, the most recently updated first (oldest with ProductQuery::OLDEST); collections keeps those named; one call for the whole catalogue, paged by offset, total counted
occurrences no
datasets yes the catalogue's own search, the best match first, 100 a page at most, total the store's count
jobs yes implements JobSourceInterface; with the key only
open yes a job's file (its data asset) or any https:// address, the key sent when set; the response streamed, its status unchecked
structure no
cross_references no
forecast no
historical no
area yes a BBox or a Point, against each dataset's extent
station no
cloud_cover no a dataset has no cloud cover: maxCloudCover is refused

Which API

The Climate Data Store's API (https://cds.climate.copernicus.eu/api), the one ECMWF's own client (ecmwf-datastores-client, formerly cads-api-client; cdsapi on top of it) calls since the store moved to its new infrastructure in 2024:

  • the catalogue, a STAC API (/catalogue/v1): every dataset as a STAC collection (/collections, the 144 in one answer), one by its id (/collections/{id}), and the store's search (/datasets?q=&sortby=relevance&page=) - open;
  • the retrieve API, OGC API - Processes (/retrieve/v1): a dataset's request form (/processes/{id}: its inputs and their values) and what a request would cost (/processes/{id}/costing) - open; a request submitted (/processes/{id}/execution), followed (/jobs/{id}), its file (/jobs/{id}/results), the account's requests (/jobs) - with the key, sent as PRIVATE-TOKEN.

The contract, and beside it

A dataset is catalogued, has a footprint and a period, and its data come as files: the core's product. products() is the contract's part; the rest is the store's own, as public methods of CdsSource:

Method Answers Calls
products(ProductQuery) the datasets that cover the Point or meet the BBox, and overlap the period (every one when the query says neither), those named in collections when it names some; the most recently updated first (OLDEST: the least); a page of limit from the cursor (an offset); total the count catalogue/v1/collections (one call, whatever the page)
datasets(Query) the catalogue's search by text, the best match first, 100 a page at most; total the store's count catalogue/v1/datasets?q=&limit=&page=&sortby=relevance
dataset($id) one dataset, with inputs (each input's title, values, default) and variables in its properties; null when unknown catalogue/v1/collections/{id}, retrieve/v1/processes/{id}
cost($id, $request) cost, limit, valid, reason (the store's words when it finds the request invalid: a period not yet available, a combination it does not have) POST retrieve/v1/processes/{id}/costing {"inputs": ...}
open(Asset|string) a job's file (or any https:// address of the store), with the key when there is one: the response, streamed, not read GET, PRIVATE-TOKEN
submit($id, $request) the request's Job (a Product): the job's id, its status (accepted) POST retrieve/v1/processes/{id}/execution {"inputs": ...}, with the key
job($jobId) the request as the store has it now, a Job: its status, its progress (100 once successful: the store gives no percentage), its request, its times; once successful, its file (file(), the data asset); null when unknown retrieve/v1/jobs/{id}?request=true, then its results link when successful
jobs(ProductQuery) the account's requests, newest first (OLDEST), limit a page, the next by the store's next link (the cursor); a query naming datasets, a place or a period is refused retrieve/v1/jobs?limit=&sortby=-created

In the contract since the core named them on 2026-10-15: datasets() and open() (Capability::DATASETS, OPEN), and submit(), job(), jobs() as the core's JobSourceInterface (Capability::JOBS), answering the core's Job - a Product, so every read of before (properties['status'], asset('data')) answers the same. dataset() and cost() stay the store's own.

The store never waits for the source: submit() returns at once, and the application asks job() again (ECMWF's client waits from one second up to two minutes between two asks, growing by half). A request's states: accepted (queued), running, successful; failed, rejected, dismissed end it without a file (CdsSource::ACCEPTED... DISMISSED).

series(), entity(), search(), occurrences(): not supported. ERA5 as series at a point is Open-Meteo's (omnilab/open-meteo serves its archive) - or a CDS request of reanalysis-era5-single-levels-timeseries, whose CSV the application reads.

What a product holds

A dataset: id and collection its id (reanalysis-era5-single-levels), start and end its period (end null when the catalogue leaves it open), bbox its footprint (the catalogue's 0 to 360 longitudes made -180 to 180), properties: title, description, keywords, license (CC-BY-4.0), sci:doi, providers, published, updated, update_frequency, disabled_reason when the store says one, and from dataset() inputs and variables; assets: thumbnail (the overview image, open; also preview), and from dataset() form and constraints (the request form and its valid combinations, JSON, role metadata); url its page (https://cds.climate.copernicus.eu/datasets/{id}).

A request: id the job's (9bfc1362-...), collection the dataset, datetime when it was created, start and end when it ran, properties: status, created, updated, request (the inputs as the store keeps them), file:checksum; assets: data once successful (href absolute - the store may answer it relative to the results' address -, type such as application/netcdf or application/x-grib, size in bytes); url the job's address.

Refused before any call

submit(), job(), jobs() without a key; jobs() with datasets, a place or a period; a cloud cover in products() (datasets have none). An answer that is not one: a ProviderException with the store's title and detail (401 "Authentication failed: operation not allowed" for a key it does not know), UnavailableException for a 5xx, RateLimitedException for a 429.

Example

<?php

require __DIR__.'/vendor/autoload.php';

use Omnilab\Cds\CdsSourceFactory;
use Omnilab\Source\Query;
use Symfony\Component\HttpClient\HttpClient;

$cds = (new CdsSourceFactory(HttpClient::create()))->create(['mailto' => 'you@example.org', 'key' => getenv('CDS_KEY') ?: null]);

$found = $cds->datasets(new Query('era5 land', limit: 3));
printf("%d datasets match \"era5 land\"; the first %d:\n", $found->total, count($found));
foreach ($found as $dataset) {
    printf("  %s  %s..%s\n", $dataset->id, $dataset->start?->format('Y-m-d'), $dataset->end?->format('Y-m-d') ?? 'open');
}

$request = ['product_type' => ['reanalysis'], 'variable' => ['2m_temperature'], 'year' => ['2025'], 'month' => ['07'], 'day' => ['01'], 'time' => ['12:00'], 'data_format' => 'netcdf', 'area' => [49, 7, 48, 8]];
$cost = $cds->cost('reanalysis-era5-single-levels', $request);
printf("one hour of 2 m temperature over Strasbourg costs %s of %s: %s\n", $cost['cost'], $cost['limit'], $cost['valid'] ? 'valid' : $cost['reason']);
37 datasets match "era5 land"; the first 3:
  reanalysis-era5-land-timeseries  1950-01-01..2026-12-31
  reanalysis-era5-land  1950-01-01..2026-10-20
  derived-era5-land-daily-statistics  1950-01-01..2026-10-20
one hour of 2 m temperature over Strasbourg costs 6 of 121000: valid

(as answered on 2026-10-27, without an account). With a key the same $request is submitted and followed to its file (the core's datasets guide): $job = $cds->submit('reanalysis-era5-single-levels', $request), $cds->job($job->id) until isDone(), then $cds->open($job->file()) - not run here (no account).

# config/packages/omnilab.yaml
omnilab:
    sources:
        cds: { factory: cds, options: { mailto: '%env(OMNILAB_MAILTO)%', key: '%env(default::CDS_KEY)%' } }

Errors

Every call goes through HttpSource: a 404 or a 410 (and a 204) is null - an empty page for a list -; a 429 is a RateLimitedException (retryAfter from Retry-After); a 5xx, a time-out or a host that does not answer an UnavailableException; any other 4xx a ProviderException with its status and what the service said; an answer that is not JSON a ProviderException, a web page answered for JSON an UnavailableException.

The refusals before any call are above. Besides: submit() of an answer without a job id is a ProviderException; the store's problem details (RFC 9457, title and detail) are read into the message. open() maps only an unreachable host (UnavailableException): the response is returned as it is, its status the caller's to check.

Verified, and not

Against the real store on 2026-10-15, without an account: the catalogue's 144 datasets, 43 of them over Strasbourg in 1950 (derived-era5-land-daily-statistics, reanalysis-era5-land, reanalysis-era5-pressure-levels... - the most recently updated first, many on 2026-10-15); the search for "era5 land" (36 matches: reanalysis-era5-land-timeseries, reanalysis-era5-land, derived-era5-land-daily-statistics first); ERA5 on single levels (1940-01-01 to 2026-10-09, CC-BY-4.0, DOI 10.24381/cds.adbb2d47, 262 variables, nine inputs: product_type, variable, year, month, day, time, area, data_format, download_format); an unknown dataset (404, null); the cost of one hour of 2 m temperature over Strasbourg (6 of a limit of 121,000), of a year of two variables hourly over the globe (105,120), of a day in 2027 (invalid: "None of the data you have requested is available yet... The latest date available for this dataset is: 2026-10-10 07:00"); a submission and a job read with a key the store does not know (401 "Authentication failed").

Not verified live (needs a key): submit(), job() and jobs() against the store - no account was used. Their answers are taken from ECMWF's own client (github.com/ecmwf/ecmwf-datastores-client: processing.py and its tests, whose job, result and listing fixtures Tests/Fixtures/job-*.json and jobs.json follow, on the store's host) and from OGC API - Processes; the store's 403 for a licence not yet accepted is its documented answer, not seen here. A result file's address is made absolute whether the store gives it absolute or, as in the client's tests, relative to the results' address.

Again on 2026-10-27, without an account: the search for "era5 land" (37 matches, the same three first) and the cost of one hour of 2 m temperature over Strasbourg (6 of 121,000, valid: the example above).

Responsibility

This package is an independent, unofficial client: it is not affiliated with, endorsed by or sponsored by ECMWF, the Copernicus Climate Change Service or the European Commission. The store is free; its datasets carry their own licences (most CC-BY-4.0, credit "Copernicus Climate Change Service"), accepted per dataset by the account, and a request beyond a dataset's cost limit is refused, as the README's Terms of use says. Whoever runs the package is responsible for complying with them and for any use made of what it returns: see the README's Responsibility section.

Propose a change docs/index.md