Omnilab is one PHP contract for the public science-data APIs, whatever their domain: chemistry,
biology, species, astronomy, the Earth, its weather and its air. An application asks every
service the same questions - what is known of this thing, what was measured at this place, what
has been catalogued over this area, what was found or happened there - and gets the same answers
back. Each service is a package of its own (omnilab/pubchem, omnilab/gbif,
omnilab/open-meteo...) that turns those questions into the service's calls.
$caffeine = $registry->get('pubchem')->entity('caffeine');
$caffeine->property('molecular_weight'); // 194.19 g/mol, from pubchem
$caffeine->identifier(Scheme::CHEBI); // 27732: a cross-reference the Collector can follow
$caffeine = $collector->collect('pubchem', 'caffeine'); // then ChEBI and Wikidata, merged
$caffeine->properties('molecular_weight'); // 194.19 g/mol from pubchem, 194.194 g/mol from chebi
[$temperature] = $registry->get('open-meteo')->series(new SeriesQuery(['temperature_2m'], new Point(48.5734, 7.7521), $tomorrow, $tomorrow));
$scenes = $registry->get('copernicus')->products(new ProductQuery(['sentinel-2-l2a'], $strasbourg, new \DateTimeImmutable('-10 days'), maxCloudCover: 30));
$lions = $registry->get('gbif')->occurrences(new OccurrenceQuery(new Point(-24.99, 31.59), $from, $to, [OccurrenceQuery::TAXON => '4CGXP'], radius: 100));
Every answer takes one of four shapes, not one per domain:
- an entity (
Model\Entity): a thing named and identified - a compound, a gene, a protein, a species, a star, an earthquake, a station - with its properties and their units (Property,Quantity), its synonyms, its identifiers in other bases (Identifier,Scheme) and the other things it is tied to (related); - a series (
Model\Series): one variable at aPoint, over aBBoxor at a station, its points in time, its unit, its step and aggregation; - a product (
Model\Product, the shape of a STAC item): a catalogued scene, granule or dataset, its footprint, properties and assets; aModel\Jobis a product made on request; - occurrences: a
Pageof entities found or happened at a place in a period, each with its time and its place.
This package holds the contract (Source\SourceInterface, Source\JobSourceInterface,
Source\SourceFactory, Source\HttpSource), the models, the Registry that builds the sources
by name, the Collector that follows an entity's cross-references from one source into the next,
the Merger that makes one entity of several while keeping the source of every property, a
STAC reader (Source\Stac) and a Symfony bundle. It requires nothing but PHP 8.2 and
symfony/http-client-contracts; each source package adds symfony/http-client.
When to use it, and when not
Use it when an application reads one or several public science services and wants their answers
in one shape: a page that describes a compound from PubChem, ChEBI and Wikidata at once; a
dashboard of the weather, the air and the rivers at a place; the observations of a species
around a park; the scenes of a satellite over a field. The Collector is the reason to take
several sources rather than one: it follows the identifiers each one gives into the others.
Do not use it for:
- publications and their metadata: they are omnisense/omnischolar's;
- decoding files: a NetCDF, a GRIB, a GeoTIFF or a CIF is opened (
open()) and handed over as an HTTP response, never read; - bulk downloads: every source reads pages a caller asks for, within the service's limits (Sources); the services' own exports are for bulk;
- converting units: a
Quantitykeeps the unit the service gave.
The providers
56 source packages, each published as omnilab/<name> and documented in its own repository.
The capabilities are what each source's capabilities() answers with its default options
(checked on 2026-10-27 by building every factory in the harness image); the key column is what
the service asks.
Chemistry and materials
| Package | Service | Capabilities | Key |
|---|---|---|---|
omnilab/pubchem |
PubChem (NCBI) | entity, search, structure, cross_references |
none (5 calls a second at most) |
omnilab/chebi |
ChEBI (EMBL-EBI) | entity, search, structure, cross_references |
none |
omnilab/chembl |
ChEMBL (EMBL-EBI) | entity, search, structure, cross_references |
none |
omnilab/kegg |
KEGG | entity, search, structure, cross_references |
none (academic use only; 3 calls a second) |
omnilab/rhea |
Rhea (SIB) | entity, search, cross_references |
none |
omnilab/cactus |
NCI/CADD Chemical Identifier Resolver | entity, structure, cross_references |
none |
omnilab/cod |
Crystallography Open Database | entity, search, open |
none (CC0) |
omnilab/materials-project |
Materials Project | entity, search |
an account's API key (free) |
omnilab/oqmd |
Open Quantum Materials Database | entity, search |
none |
omnilab/codata |
CODATA constants (NIST) | entity, search |
none |
Genes, proteins, pathways, medicine
| Package | Service | Capabilities | Key |
|---|---|---|---|
omnilab/uniprot |
UniProtKB | entity, search, cross_references |
none |
omnilab/ncbi |
NCBI Gene and Taxonomy | entity, search, cross_references |
none (3 calls a second; 10 with an API key) |
omnilab/ensembl |
Ensembl | entity, search, cross_references |
none (15 calls a second) |
omnilab/pdb |
RCSB PDB | entity, search, structure |
none (a handful of calls a second) |
omnilab/reactome |
Reactome | entity, search |
none |
omnilab/string |
STRING | entity, cross_references |
none (one call a second, caller_identity) |
omnilab/interpro |
InterPro | entity, search |
none |
omnilab/quickgo |
QuickGO (Gene Ontology) | entity, search |
none |
omnilab/hgnc |
HGNC | entity, search, cross_references |
none (10 calls a second) |
omnilab/opentargets |
Open Targets Platform | entity, search, cross_references |
none |
omnilab/clinvar |
ClinVar (NCBI) | entity, search, cross_references |
none (3 calls a second; 10 with an API key) |
omnilab/gwas |
GWAS Catalog | entity, search |
none |
Species and occurrences
| Package | Service | Capabilities | Key |
|---|---|---|---|
omnilab/gbif |
GBIF | entity, search, occurrences, area, cross_references |
none |
omnilab/inaturalist |
iNaturalist | entity, search, occurrences, area |
none (60 calls a minute) |
omnilab/col |
Catalogue of Life (ChecklistBank) | entity, search, cross_references |
none |
omnilab/itis |
ITIS | entity, search |
none |
omnilab/worms |
WoRMS | entity, search, cross_references |
none |
omnilab/obis |
OBIS | entity, occurrences, area, cross_references |
none |
omnilab/pbdb |
Paleobiology Database | entity, search |
none |
omnilab/ebird |
eBird API 2.0 | entity, search, occurrences |
a key (the user's) for all but the taxonomy |
omnilab/iucn |
IUCN Red List API v4 | entity |
a token (the user's, required) |
Astronomy and space
| Package | Service | Capabilities | Key |
|---|---|---|---|
omnilab/simbad |
SIMBAD (CDS) | entity, search |
none |
omnilab/gaia |
ESA Gaia DR3 | entity |
none |
omnilab/exoplanets |
NASA Exoplanet Archive | entity, search |
none |
omnilab/ned |
NASA/IPAC Extragalactic Database | entity |
none |
omnilab/vizier |
VizieR (CDS) | datasets |
none |
omnilab/jpl |
JPL Small-Body Database | entity, search |
none |
omnilab/mpc |
IAU Minor Planet Center | entity, search, cross_references |
none |
omnilab/horizons |
JPL Horizons | series, forecast, historical |
none (one request at a time) |
omnilab/donki |
NASA DONKI | entity |
none |
omnilab/launches |
Launch Library 2 | entity, search, occurrences |
none (15 calls an hour; a supporter's key) |
omnilab/swpc |
NOAA Space Weather Prediction Center | series, forecast |
none |
The Earth, weather and air
| Package | Service | Capabilities | Key |
|---|---|---|---|
omnilab/usgs |
USGS earthquakes (ComCat) | entity, series, occurrences, historical, area |
none |
omnilab/emsc |
EMSC earthquakes (SeismicPortal) | entity, series, occurrences, historical, area |
none |
omnilab/open-meteo |
Open-Meteo (weather, air quality) | series, forecast, historical |
none for non-commercial use |
omnilab/noaa |
NOAA NCEI and National Weather Service | entity, series, historical, station |
none (a User-Agent for the NWS) |
omnilab/brightsky |
Bright Sky (DWD open data) | entity, series, forecast, historical, station |
none |
omnilab/openaq |
OpenAQ API v3 | series, historical |
a key (required) |
omnilab/usgs-water |
USGS Water Data | entity, search, series, historical, station |
none (an api.data.gov key raises the limit) |
omnilab/tides |
NOAA CO-OPS Tides and Currents | entity, series, forecast, historical, station |
none |
omnilab/elevation |
Open Topo Data | none of the contract's (methods of its own) | none (100 locations a call, 1,000 calls a day) |
Catalogues of products and datasets
| Package | Service | Capabilities | Key |
|---|---|---|---|
omnilab/copernicus |
Copernicus Data Space (STAC) | products, area, cloud_cover |
none to search (a CDSE token to download) |
omnilab/cds |
Copernicus Climate Data Store | products, area, datasets, jobs, open |
none to read (an account's key to submit) |
omnilab/cams |
Copernicus Atmosphere Data Store | products, area, datasets, jobs, open |
none to read (an account's key to submit) |
omnilab/earthdata |
NASA Earthdata (CMR) | products, area, cloud_cover, datasets, open |
none to search (an Earthdata Login token to download) |
Knowledge graph
| Package | Service | Capabilities | Key |
|---|---|---|---|
omnilab/wikidata |
Wikidata | entity, search, cross_references |
none (a User-Agent that names the caller; 200 calls a minute) |
Capabilities, in short (every case): entity, search, series,
products, occurrences, datasets, jobs and open are the operations a source answers;
structure, cross_references, forecast, historical, area, station and cloud_cover say
what its answers hold or which queries it takes. An operation a source does not answer throws
NotSupportedException before any call. omnilab/elevation answers none of the contract's
operations: its at() and profile() are methods of its own.
How this manual is organised
- Installation: the packages, plain PHP first with a complete script and its real output, then the frameworks.
- Symfony: the bundle, its configuration tree, the services it autowires.
- Laravel: no bridge; the registry as a singleton in a service provider.
- Models and identifiers: every contract, model, field, enum case and scheme.
- Sources: what each source can and cannot do, what the services do not allow, throttling and politeness.
- Occurrences, Datasets, requests, files and Following cross-references, merging: the usage guides.
- Configuration reference: the registry's shape, the options every source takes, each source's own options with their defaults.
- Errors: every exception, when it is thrown, what to catch.
- Testing: testing an application that uses Omnilab, and the packages' own tests.
- The Docker harness: every source installed, a console that asks the real services.
- Writing a provider: a new
omnilab/<name>package, step by step. - Verified, and not: what the tests prove, what ran live and when, what did not.
- Decisions: what the contract settled, and why.
Propose a change docs/index.md
Installation and first calls
composer require omnisense/omnilab omnilab/pubchem omnilab/open-meteo omnilab/copernicus
composer require omnilab/chebi omnilab/wikidata # the sources the Collector follows a compound into
composer require omnilab/uniprot omnilab/ncbi omnilab/gbif # proteins, genes and taxa
composer require omnilab/chembl # bioactive molecules, their targets
composer require omnilab/simbad omnilab/jpl # stars and galaxies; asteroids and comets
composer require omnilab/gaia omnilab/exoplanets # Gaia DR3's sources; the confirmed exoplanets
composer require omnilab/ned omnilab/vizier # galaxies and quasars (NED); the CDS's catalogues (VizieR)
composer require omnilab/mpc omnilab/horizons # minor planets and comets (MPC); ephemerides seen from a place (JPL Horizons)
composer require omnilab/donki omnilab/launches # space weather events (NASA DONKI); orbital launches (Launch Library 2)
composer require omnilab/usgs omnilab/emsc # earthquakes: the USGS's catalogue, the EMSC's
composer require omnilab/ensembl omnilab/pdb # genomes; protein structures
composer require omnilab/reactome # pathways and reactions
composer require omnilab/hgnc omnilab/string omnilab/interpro omnilab/quickgo # human genes; protein interactions, domains, GO
composer require omnilab/opentargets omnilab/clinvar omnilab/gwas # targets and diseases; clinical variants; GWAS associations
composer require omnilab/inaturalist # taxa, and observations of species
composer require omnilab/col omnilab/itis omnilab/worms omnilab/obis # the Catalogue of Life (GBIF's taxonomy); ITIS; marine taxa and their records
composer require omnilab/pbdb # fossils: taxa, their ages, the fossils of a place
composer require omnilab/ebird omnilab/iucn # birds (eBird: a key beyond the taxonomy); the IUCN Red List (a token)
composer require omnilab/noaa # weather stations' records (NCEI), the NWS
composer require omnilab/cds omnilab/cams omnilab/earthdata # climate and atmosphere data stores; NASA's Earth observation
composer require omnilab/rhea omnilab/kegg # biochemical reactions; KEGG (academic use only)
composer require omnilab/cod omnilab/cactus omnilab/codata # crystal structures; a structure from any identifier; physical constants
composer require omnilab/materials-project omnilab/oqmd # computed materials (Materials Project: a key)
composer require omnilab/usgs-water omnilab/tides # rivers and wells (USGS); tides and water levels (NOAA CO-OPS)
composer require omnilab/brightsky omnilab/openaq # the DWD's weather (Bright Sky); air quality measurements (OpenAQ: a key)
composer require omnilab/swpc omnilab/elevation # space weather (NOAA SWPC); elevations (Open Topo Data)
The weather and the air are both omnilab/open-meteo (api: air_quality for the air); the
occurrences of a species are omnilab/gbif's, omnilab/inaturalist's (its observations), omnilab/obis's (in
the sea) and omnilab/ebird's (birds, with a key) - a fossil's are outside the contract (omnilab/pbdb's
fossils(), an age being no date) -, those
of earthquakes omnilab/usgs's and omnilab/emsc's.
PHP 8.2 or later.
Omnilab needs no framework. The core requires nothing but symfony/http-client-contracts, each
source package symfony/http-client: two libraries that stand alone. It runs the same in plain
PHP, in a worker, in Laravel or Slim, and in Symfony, where a bundle does the wiring
(Symfony).
Requirements
| Requires | |
|---|---|
omnisense/omnilab |
PHP 8.2 or later, symfony/http-client-contracts ^3.0 |
each omnilab/<source> |
PHP 8.2 or later, omnisense/omnilab ^1.0@dev, symfony/http-client ^6.4, ^7.0 or ^8.0; omnilab/cams requires omnilab/cds besides |
| the Symfony bundle | symfony/config, symfony/dependency-injection, symfony/http-kernel ^6.4, ^7.0 or ^8.0 (suggested, not required: a Symfony application has them) |
No PHP extension is declared. The core decodes JSON (ext-json, always present since PHP 8.0);
thirteen sources (chebi, chembl, codata, ebird, ensembl, exoplanets, gbif, inaturalist, ncbi,
simbad, uniprot, vizier, wikidata) call mb_strtolower() and the other mb_* functions.
Each of them requires ext-mbstring in its composer.json.
No version is tagged yet: every package is installed from its 1.x branch (1.x-dev). An
application that requires them either writes each constraint 1.x-dev
(composer require omnisense/omnilab:1.x-dev omnilab/pubchem:1.x-dev) or sets
"minimum-stability": "dev" and "prefer-stable": true in its composer.json. The source
packages require the core as ^1.0@dev.
Plain PHP
<?php // bare.php
require __DIR__.'/vendor/autoload.php';
use Omnilab\Copernicus\CopernicusSourceFactory;
use Omnilab\Model\Point;
use Omnilab\Model\Scheme;
use Omnilab\OpenMeteo\OpenMeteoSourceFactory;
use Omnilab\PubChem\PubChemSourceFactory;
use Omnilab\Registry;
use Omnilab\Source\ProductQuery;
use Omnilab\Source\SeriesQuery;
use Symfony\Component\HttpClient\HttpClient;
$http = HttpClient::create();
$registry = new Registry([new PubChemSourceFactory($http), new OpenMeteoSourceFactory($http), new CopernicusSourceFactory($http)], [
'pubchem' => ['factory' => 'pubchem', 'options' => ['mailto' => getenv('OMNILAB_MAILTO') ?: null]], // your address, in the User-Agent
'meteo' => ['factory' => 'open-meteo'],
'copernicus' => ['factory' => 'copernicus'],
]);
// An entity: caffeine, from PubChem.
$caffeine = $registry->get('pubchem')->entity('caffeine');
echo $caffeine->label, ' (', $caffeine->id, '): ', $caffeine->value('molecular_formula'), ', ', $caffeine->property('molecular_weight'), ', XLogP ', $caffeine->property('xlogp'), ', TPSA ', $caffeine->property('tpsa'), "\n";
echo ' CAS ', $caffeine->identifier(Scheme::CAS), ', ChEBI ', $caffeine->identifier(Scheme::CHEBI), ', ChEMBL ', $caffeine->identifier(Scheme::CHEMBL), ', Wikidata ', $caffeine->identifier(Scheme::WIKIDATA), "\n";
echo ' ', $caffeine->image, "\n";
// A series: tomorrow's hourly temperature in Strasbourg, from Open-Meteo.
$strasbourg = new Point(48.5734, 7.7521);
$tomorrow = new DateTimeImmutable('tomorrow', new DateTimeZone('Europe/Paris'));
[$temperature] = $registry->get('meteo')->series(new SeriesQuery(['temperature_2m'], $strasbourg, $tomorrow, $tomorrow));
echo "\nStrasbourg, ", $tomorrow->format('Y-m-d'), ', from ', $temperature->min(), ' to ', $temperature->max(), ' ', $temperature->unit, ":\n ";
foreach ($temperature as [$time, $value]) {
echo ' ', $time->format('H'), 'h ', $value;
}
echo "\n";
// Products: the Sentinel-2 L2A scenes over Strasbourg in the last ten days, under 30 % cloud.
$scenes = $registry->get('copernicus')->products(new ProductQuery(['sentinel-2-l2a'], $strasbourg, new DateTimeImmutable('-10 days'), maxCloudCover: 30));
echo "\nSentinel-2 L2A over Strasbourg since ", (new DateTimeImmutable('-10 days'))->format('Y-m-d'), ":\n";
foreach ($scenes as $scene) {
printf(" %s %5.2f %% cloud %s\n", $scene->datetime->format('Y-m-d H:i'), $scene->cloudCover(), $scene->id);
}
$ OMNILAB_MAILTO=you@example.org php bare.php
Caffeine (pubchem.cid:2519): C8H10N4O2, 194.19 g/mol, XLogP -0.1, TPSA 58.4 Ų
CAS 58-08-2, ChEBI 27732, ChEMBL CHEMBL113, Wikidata Q60235
https://pubchem.ncbi.nlm.nih.gov/rest/pug/compound/cid/2519/PNG
Strasbourg, 2026-10-16, from 10.4 to 20.4 °C:
00h 12.2 01h 11.3 02h 11 03h 10.7 04h 10.7 05h 10.6 06h 10.5 07h 10.4 08h 10.4 09h 10.8 10h 11.4 11h 12 12h 13.2 13h 15.4 14h 17.3 15h 18.7 16h 19.9 17h 20.4 18h 20.2 19h 19.3 20h 18.5 21h 18 22h 17.3 23h 16.6
Sentinel-2 L2A over Strasbourg since 2026-10-04:
2026-10-12 10:29 28.94 % cloud S2A_MSIL2A_20261012T102911_N0513_R108_T32UMU_20261012T170607
2026-10-12 10:29 1.84 % cloud S2A_MSIL2A_20261012T102911_N0513_R108_T32ULU_20261012T170607
2026-10-05 10:28 11.10 % cloud S2B_MSIL2A_20261005T102859_N0513_R108_T32UMU_20261005T142235
2026-10-05 10:28 5.62 % cloud S2B_MSIL2A_20261005T102859_N0513_R108_T32ULU_20261005T142235
(as answered on 2026-10-15; the scenes' times are UTC, the temperatures Strasbourg's hours)
Run again on 2026-10-27, in the harness image against the workspace's checkouts: the same compound, another day's temperatures, and no Sentinel-2 scene under 30 % cloud in those ten days - an empty page, not an error:
Caffeine (pubchem.cid:2519): C8H10N4O2, 194.19 g/mol, XLogP -0.1, TPSA 58.4 Ų
CAS 58-08-2, ChEBI 27732, ChEMBL CHEMBL113, Wikidata Q60235
https://pubchem.ncbi.nlm.nih.gov/rest/pug/compound/cid/2519/PNG
Strasbourg, 2026-10-28, from 7.8 to 14.9 °C:
00h 12.7 01h 13.1 02h 12.9 03h 13.9 04h 13.5 05h 13.2 06h 12.5 07h 11.6 08h 11 09h 11.4 10h 12.2 11h 13.2 12h 14.1 13h 14.6 14h 14.9 15h 14.8 16h 14.5 17h 14 18h 13.3 19h 12.3 20h 11.1 21h 10.1 22h 8.9 23h 7.8
Sentinel-2 L2A over Strasbourg since 2026-10-17:
No key, no account: the script asks the real services. That is all there is to it:
- a factory per source package (
PubChemSourceFactory,OpenMeteoSourceFactory,CopernicusSourceFactory), which takes the HTTP client to call with - the application's, aMockHttpClientin a test; with none given it makes its own (HttpClient::create()); - the registry, built by hand from the factories and the sources' options, by name;
- the sources it gives, and what works on them: the
Collector, theMerger.
No class of a framework is loaded on the way - a test of this package checks it in a process of
its own (Tests/BareTest.php), and so does docker compose run --rm omnilab bare
(harness).
One source
use Omnilab\Model\Identifier;
use Omnilab\PubChem\PubChemSourceFactory;
use Omnilab\Source\Query;
use Symfony\Component\HttpClient\HttpClient;
$pubchem = (new PubChemSourceFactory(HttpClient::create()))->create(['mailto' => 'support@glitchr.io']);
$pubchem->entity('caffeine'); // by name
$pubchem->entity('RYYVLZVUVIJVGH-UHFFFAOYSA-N'); // by InChIKey
$pubchem->entity('CHEBI:27732'); // by an identifier PubChem keeps among its synonyms
$pubchem->entity(Identifier::cid(2519));
$page = $pubchem->search(new Query('caffeine', limit: 10));
foreach ($page as $compound) {
echo $compound->id, ' ', $compound->label, ' ', $compound->property('molecular_weight'), "\n";
}
$next = $pubchem->search(new Query('caffeine', limit: 10, cursor: $page->next)); // the next page
An entity is named by an Identifier or a string: one of the source's identifiers ('2519',
'CHEBI:27732', '58-08-2'), a page of a base (https://pubchem.ncbi.nlm.nih.gov/compound/2519,
https://identifiers.org/chebi:27732) or, for the sources that look things up by name, a name.
A series, products
use Omnilab\Model\BBox;
use Omnilab\Model\Point;
use Omnilab\Model\Series;
use Omnilab\Source\ProductQuery;
use Omnilab\Source\SeriesQuery;
$meteo = $registry->get('meteo');
$copernicus = $registry->get('copernicus');
$strasbourg = new Point(48.5734, 7.7521);
[$max, $rain] = $meteo->series(new SeriesQuery(['temperature_2m', 'precipitation_sum'], $strasbourg,
new \DateTimeImmutable('2026-09-01'), new \DateTimeImmutable('2026-09-30'), SeriesQuery::DAILY, Series::MAX));
$max->variable; // temperature_2m_max: Open-Meteo's name for it; precipitation_sum says its own and is kept
$max->forecast; // false: the archive answered
$page = $copernicus->products(new ProductQuery(['sentinel-1-grd'], new BBox(7.68, 48.53, 7.83, 48.64), new \DateTimeImmutable('-7 days')));
foreach ($page as $scene) {
echo $scene->datetime->format(\DATE_ATOM), ' ', $scene->platform(), ' ', $scene->asset('Product')?->href, "\n";
}
Air quality
$air = (new OpenMeteoSourceFactory($http))->create(['api' => 'air_quality']);
$day = new \DateTimeImmutable('2026-10-14');
foreach ($air->series(new SeriesQuery(['pm2_5', 'pm10', 'nitrogen_dioxide', 'ozone', 'european_aqi', 'us_aqi'], $strasbourg, $day, $day)) as $series) {
printf("%-17s %-6s from %s to %s\n", $series->variable, $series->unit, $series->min(), $series->max());
}
pm2_5 μg/m³ from 5.8 to 19
pm10 μg/m³ from 7.7 to 24.9
nitrogen_dioxide μg/m³ from 7 to 41.4
ozone μg/m³ from 16 to 73
european_aqi EAQI from 22 to 49
us_aqi USAQI from 41 to 51
(Strasbourg, 2026-10-14, as answered on 2026-10-15)
Occurrences
What was found or happened at a place in a period - the fourth shape (occurrences):
use Omnilab\Gbif\GbifSourceFactory;
use Omnilab\Source\OccurrenceQuery;
use Omnilab\Usgs\UsgsSourceFactory;
$gbif = (new GbifSourceFactory($http))->create(['mailto' => 'you@example.org']);
$lions = $gbif->occurrences(new OccurrenceQuery(new Point(-24.99, 31.59), new \DateTimeImmutable('2025-01-01'),
new \DateTimeImmutable('2025-12-31'), [OccurrenceQuery::TAXON => '4CGXP'], radius: 100, limit: 300));
echo $lions->total, " lions within 100 km of Skukuza in 2025\n";
$usgs = (new UsgsSourceFactory($http))->create(['mailto' => 'you@example.org']);
$quakes = $usgs->occurrences(new OccurrenceQuery(new BBox(-25, 35, 45, 72), new \DateTimeImmutable('2026-10-01'),
new \DateTimeImmutable('2026-10-07'), [OccurrenceQuery::MIN_MAGNITUDE => 4], limit: 3, sort: 'magnitude'));
foreach ($quakes as $quake) {
echo $quake->time->format('Y-m-d H:i:s'), ' UTC ', $quake->place, ' ', $quake->label, "\n";
}
412 lions within 100 km of Skukuza in 2025
2026-10-04 11:43:23 UTC 37.7292,36.1272 M 5.0 - 21 km ESE of Feke, Turkey
2026-10-04 11:36:26 UTC 37.7757,36.1685 M 4.7 - 22 km E of Feke, Turkey
2026-10-04 11:19:26 UTC 37.8028,36.1749 M 4.7 - 21 km SSE of Saimbeyli, Turkey
(as answered on 2026-10-15; printf pads μg/m³ by its bytes; two earthquakes of one magnitude
come in either order)
Several sources
use Omnilab\Chebi\ChebiSourceFactory;
use Omnilab\Collector;
use Omnilab\PubChem\PubChemSourceFactory;
use Omnilab\Registry;
use Omnilab\Wikidata\WikidataSourceFactory;
$registry = new Registry([new PubChemSourceFactory($http), new ChebiSourceFactory($http), new WikidataSourceFactory($http)], [
'pubchem' => ['factory' => 'pubchem', 'options' => ['mailto' => 'you@example.org']],
'chebi' => ['factory' => 'chebi', 'options' => ['mailto' => 'you@example.org']],
'wikidata' => ['factory' => 'wikidata', 'options' => ['mailto' => 'you@example.org']],
]);
$collector = new Collector($registry);
$caffeine = $collector->collect('pubchem', 'caffeine'); // then its InChIKey offered to ChEBI, its CID to Wikidata
$caffeine->sources; // those that answered: pubchem, chebi, wikidata
$collector->incomplete; // the sources skipped (down, rate limited): do not cache this answer
foreach (['molecular_weight', 'monoisotopic_mass', 'is_a', 'melting_point'] as $key) {
echo $key, ': ', implode(' ', array_map(fn ($p) => $p.' ['.$p->source.']', $caffeine->properties($key))), "\n";
}
molecular_weight: 194.19 g/mol [pubchem] 194.194 g/mol [chebi]
monoisotopic_mass: 194.08037557 Da [pubchem] 194.08038 Da [chebi]
is_a: purine alkaloid [chebi] trimethylxanthine [chebi]
melting_point: 234 °C [wikidata] 235 °C [wikidata]
(as answered on 2026-10-15)
See Following cross-references, merging.
In a framework
- Symfony:
Omnilab\Bridge\Symfony\OmnilabBundleregisters the factories on the application'shttp_client, builds the registry fromconfig/packages/omnilab.yaml, and autowires each source by its name, theCollectorand theMerger: see Symfony. Its components (symfony/config,symfony/dependency-injection,symfony/http-kernel) are not required by this package: a Symfony application has them. - Laravel: there is no Laravel bridge for Omnilab; a service provider of the application
builds the
Registryas a singleton: see Laravel. - Any other: build the
Registryonce, where the framework builds its services (a service provider, a container definition), as the script above does.
Errors
| Exception | When |
|---|---|
NotSupportedException |
the source does not do that, or cannot read that identifier: thrown before any call |
RateLimitedException |
429: slow down ($retryAfter seconds, when the service says) |
UnavailableException |
5xx (PubChem's 503 "server busy"), a timeout, a network error: never "nothing" |
ProviderException |
any other error answer (an unknown variable, collection or structure: 400), with the service's reason |
InvalidConfigException |
a source not configured, a factory not installed, an option missing |
All implement Omnilab\Exception\OmnilabException. Not found is null, an empty page, or a
series without points (no series for a station the source does not know).
Propose a change docs/installation.md
Symfony
Omnilab runs without a framework (installation); in a Symfony application its
bundle does the wiring. Its components - symfony/config, symfony/dependency-injection,
symfony/http-kernel - are not required by omnisense/omnilab: the application has them, and
nothing of them is loaded outside Symfony.
The bundle is Omnilab\Bridge\Symfony\OmnilabBundle, in this package (an AbstractBundle, its
extension alias omnilab). There is no omnibridge/symfony integration for Omnilab: this bundle
is the whole of it.
Registration
Register the bundle (no Flex recipe):
// config/bundles.php
return [
// ...
Omnilab\Bridge\Symfony\OmnilabBundle::class => ['all' => true],
];
# config/packages/omnilab.yaml
omnilab:
sources: # by name: a factory and its options
pubchem: { factory: pubchem, options: { mailto: '%env(OMNILAB_MAILTO)%' } }
meteo: { factory: open-meteo, options: { api_key: '%env(default::OPEN_METEO_API_KEY)%' } }
copernicus: { factory: copernicus, options: { collections: [sentinel-2-l2a] } }
merger:
preferences: # field or property key => sources, "default" for the others
default: [pubchem]
The configuration tree
As config:dump-reference writes it (dumped from the bundle's configure() with Symfony 8.1's
YamlReferenceDumper on 2026-10-27):
omnilab:
# The sources, by name: a factory (pubchem, chebi, wikidata, uniprot, ensembl, pdb, reactome, string, interpro, quickgo, hgnc, opentargets, clinvar, gwas, gbif, inaturalist, simbad, gaia, exoplanets, ned, vizier, mpc, horizons, donki, launches, usgs, open-meteo, noaa, copernicus, cds, cams, earthdata, kegg, rhea, cod, cactus, codata, materials-project, oqmd, emsc, usgs-water, tides, swpc, brightsky, elevation, openaq, obis, worms, itis, col, pbdb, ebird, iucn...) and its options.
sources:
# Prototype
name:
factory: ~ # Required
options: []
merger:
# For a field (label, description, image...) or a property key (molecular_weight...), the sources in the order they are trusted; "default" for the others.
preferences:
# Prototype
field: []
| Key | Type | Default | |
|---|---|---|---|
sources |
map, by source name | {} |
the sources the Registry builds; their order is the order names(), all() and the Collector follow; a name is kept as written (air-quality is the Registry's air-quality and the service omnilab.source.air-quality: the keys are not normalised) |
sources.<name>.factory |
string, required, not empty | - | the factory's name (pubchem, open-meteo...: every one) or an application's own factory |
sources.<name>.options |
any (a map) | [] |
handed to the factory's create() as they are: the options every source takes and the source's own (Configuration reference) |
merger.preferences |
map of lists of strings | {} |
for a field (label, kind, description, url, image, time, place) or a property key, the sources in the order they are trusted; default for every other (the Merger) |
One source package may be configured several times under different names, each with its own
options: Open-Meteo's weather and its air quality (api: air_quality), a CDS source with an
account's key beside one without.
Services
What loadExtension() registers:
| Service id | Class | Public | |
|---|---|---|---|
each factory class installed (Omnilab\PubChem\PubChemSourceFactory...) |
itself | no | built on http_client (null when the application has none: the factory then makes its own client); tagged omnilab.source_factory |
Omnilab\Registry |
Registry |
yes | every factory tagged omnilab.source_factory, and sources |
omnilab.source.<name> |
SourceInterface |
no | Registry::get('<name>'), for each configured source: built when first used |
Omnilab\Merger |
Merger |
no | merger.preferences |
Omnilab\Collector |
Collector |
no | the Registry and the Merger above |
The bundle knows the 56 factories of the omnilab/* packages (OmnilabBundle::FACTORIES) and
registers each one whose class exists: a package not installed costs nothing. Every class that
implements SourceFactoryInterface is autoconfigured with the omnilab.source_factory tag, so
an application's own factory (a class of its src/) is in the Registry too, and can be named
in factory: by its getName().
Autowiring
| Argument | Gets |
|---|---|
SourceInterface $pubchem |
the source configured as pubchem: one named alias per source (SourceInterface $<name>, the name camel-cased: a source air_quality is SourceInterface $airQuality) |
Registry $registry |
every configured source by name: get(), has(), names(), all(), factories(), options() |
Collector $collector |
collect('pubchem', 'caffeine'), follow(), search() |
Merger $merger |
with the configured preferences |
use Omnilab\Collector;
use Omnilab\Source\SourceInterface;
final class Lab
{
public function __construct(
private readonly SourceInterface $pubchem, // omnilab.source.pubchem
private readonly SourceInterface $meteo, // omnilab.source.meteo
private readonly Collector $collector,
) {
}
}
A SourceInterface argument whose name is no configured source is not autowired: Symfony says
the service cannot be resolved. The Registry is public, so $container->get(Registry::class)
works in a test.
Commands, events, Twig
None: the bundle registers no console command, dispatches no event and adds no Twig function.
The harness's console (docker/harness/bin/omnilab) is a script of the harness, not a
Symfony command (harness).
In a controller
final class CompoundController extends AbstractController
{
#[Route('/compounds/{name}')]
public function show(string $name, Collector $collector): Response
{
$compound = $collector->collect('pubchem', $name) ?? throw $this->createNotFoundException();
return $this->render('compound/show.html.twig', ['compound' => $compound]);
}
}
Cache what the sources answer (they are remote, and some count their calls: PubChem by the
minute, Open-Meteo by the day), except when $collector->incomplete is not empty, and never an
UnavailableException as "nothing" (Errors).
What is tested
Tests/Bridge/OmnilabBundleTest.php boots a kernel with the bundle and checks that the 56
factories installed in the workspace are registered, that an application's own factory is
autoconfigured, that two sources are injected by their argument names, and that the configured
preferences reach the Collector's Merger.
Propose a change docs/symfony.md
Laravel
There is no Laravel bridge for Omnilab: omnibridge/laravel has no provider for this family, and
this package ships none. Nothing is needed beyond what plain PHP does
(installation): a service provider of the application builds the
Registry once, as a singleton, from the factories of the source packages it installed.
A service provider of the application's own
<?php // config/omnilab.php
return [
'sources' => [
'pubchem' => ['factory' => 'pubchem', 'options' => ['mailto' => env('OMNILAB_MAILTO')]],
'chebi' => ['factory' => 'chebi', 'options' => ['mailto' => env('OMNILAB_MAILTO')]],
'wikidata' => ['factory' => 'wikidata', 'options' => ['mailto' => env('OMNILAB_MAILTO'), 'language' => 'en']],
'meteo' => ['factory' => 'open-meteo'],
'air-quality' => ['factory' => 'open-meteo', 'options' => ['api' => 'air_quality']],
],
'preferences' => [
'default' => ['pubchem'],
],
];
<?php // app/Providers/OmnilabServiceProvider.php
namespace App\Providers;
use Illuminate\Contracts\Foundation\Application;
use Illuminate\Support\ServiceProvider;
use Omnilab\Chebi\ChebiSourceFactory;
use Omnilab\Collector;
use Omnilab\Merger;
use Omnilab\OpenMeteo\OpenMeteoSourceFactory;
use Omnilab\PubChem\PubChemSourceFactory;
use Omnilab\Registry;
use Omnilab\Wikidata\WikidataSourceFactory;
use Symfony\Component\HttpClient\HttpClient;
final class OmnilabServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->singleton(Registry::class, static function (): Registry {
$http = HttpClient::create();
return new Registry([
new PubChemSourceFactory($http),
new ChebiSourceFactory($http),
new WikidataSourceFactory($http),
new OpenMeteoSourceFactory($http),
], config('omnilab.sources'));
});
$this->app->singleton(Merger::class, static fn (): Merger => new Merger(config('omnilab.preferences', [])));
$this->app->singleton(Collector::class, static fn (Application $app): Collector => new Collector($app->make(Registry::class), $app->make(Merger::class)));
}
}
Register the provider where the application lists its providers (bootstrap/providers.php since
Laravel 11, config/app.php's providers before). The Registry, the Merger and the
Collector are then injected by their class:
use Omnilab\Collector;
use Omnilab\Registry;
final class CompoundController
{
public function show(string $name, Collector $collector, Registry $registry)
{
$compound = $collector->collect('pubchem', $name) ?? abort(404);
$meteo = $registry->get('meteo');
// ...
}
}
What differs from the Symfony bundle
- The factories are listed by hand: the bundle registers every
omnilab/*package installed (Symfony), a hand-written provider only those it names. A factory not listed makesRegistry::get()throwInvalidConfigException("No "x" factory for the "y" source; installed: ..."). - No source is injected by its name: there is no
SourceInterface $pubchemalias. Inject theRegistryand ask it ($registry->get('pubchem')), or bind a name of the application's own ($this->app->bind('lab.pubchem', fn ($app) => $app->make(Registry::class)->get('pubchem'))). - The source names are kept as written:
air-qualitystaysair-quality, as under the Symfony bundle (its configuration tree). - The HTTP client is Symfony's (
symfony/http-client, which every source package requires), not Laravel'sHttpfacade: the factories take aSymfony\Contracts\HttpClient\HttpClientInterface.
This provider is plain construction of the classes documented in this manual; it has not been run in a Laravel application (the harness has none).
Propose a change docs/laravel.md
Models and identifiers
All models are read-only (readonly classes); Entity::with([...]) and the queries' with([...])
give modified copies.
Entity
| Field | |
|---|---|
id |
the Identifier the source keeps it under |
label |
its name |
domain |
Domain: chemistry, biology, taxonomy, astronomy, earth, other |
kind |
the source's word for what it is: compound, protein, species, galaxy, earthquake |
synonyms |
the other names it goes by |
description |
|
properties |
Property list: key, label, value (a Quantity, a text, a number, a boolean), source |
identifiers |
Identifiers: its own, then the cross-references to other bases - other names of the same thing; identifiers() adds id |
related |
array<string, Identifiers>: other things it is tied to, by relation - a protein's organism (ncbi.taxon:9606), its structures (pdb:1A3N), its ligands and cofactors (chebi:60344), a taxon's parent, an occurrence's taxon; related($relation) gives one relation's (why a field of its own) |
url, image |
its page; its picture (a structure, where the source has Capability::STRUCTURE) |
time, place |
for what happens somewhere at a moment (an earthquake, an occurrence of a species) or sits somewhere (a station): a Point or a BBox; every entity occurrences() answers has both (occurrences) |
source, sources |
the source of the record; after a merge, every source merged |
Identifiers name the thing itself: the Collector follows them into the other sources, and the
Merger takes two entities that share one for the same thing. Related things are not followed and
join nothing: a protein and the human species share the taxon 9606 without being one.
property($key) gives the preferred source's property of a key, properties($key) every source's,
value($key) its value, identifier(Scheme::CAS) the first value of a scheme, keys() the keys
known.
Property keys are lower case, snake case, and mean the same from one source to the next:
molecular_formula, molecular_weight, exact_mass, monoisotopic_mass, iupac_name,
smiles, inchi, inchikey, xlogp, tpsa, complexity, charge, h_bond_donors,
h_bond_acceptors, rotatable_bonds, heavy_atoms (PubChem); chebi_stars, is_a,
has_role, has_functional_parent, is_conjugate_acid_of... (ChEBI: one key per relation of its
ontology); connectivity_smiles, mass, melting_point, boiling_point, flash_point,
density, pka, taxon_name, radius, orbital_period, redshift, distance_from_earth,
elevation, richter_magnitude (Wikidata); gene, organism, organism_common_name,
sequence_length, sequence, reviewed, protein_existence, annotation_score, ec_number,
subcellular_location, cofactor, ligand, keyword (UniProt; mass as Wikidata's); gene_name,
gene_type, chromosome, map_location, genomic_location, taxon_rank, taxon_authority,
common_name, group, basionym, lineage and one key per rank of the lineage (kingdom,
phylum, class, order, family, genus) (NCBI; taxon_name as Wikidata's). A new source reuses a key that exists before it makes
one; a key may hold several values of one source (properties('has_role')).
Property
Model\Property (readonly, Stringable): one thing known of an entity.
| Field | Type | |
|---|---|---|
key |
string |
lower case, snake case, the same from one source to the next when they mean the same (above) |
label |
string |
to show: Molecular weight |
value |
Quantity|string|int|float|bool |
a Quantity when it has a unit, else a text, a number or a boolean |
source |
?string |
the source that said it: the Merger keeps it through a merge |
number() gives the number (a Quantity's included; null for a text or a boolean), unit() a
Quantity's unit (null otherwise), with([...]) a modified copy, and (string) the value as
text: 194.19 g/mol, C8H10N4O2, yes / no for a boolean, a float as a Quantity without
unit writes it.
new Property('molecular_weight', 'Molecular weight', new Quantity(194.19, 'g/mol'), 'pubchem');
new Property('molecular_formula', 'Molecular formula', 'C8H10N4O2', 'pubchem');
Domain
Model\Domain, a string-backed enum, says what an entity is a thing of - coarse on purpose (a
source says what kind of thing within its domain in Entity::$kind, in its own word):
| Case | Value | label() |
Holds |
|---|---|---|---|
CHEMISTRY |
chemistry |
Chemistry | compounds, substances, elements, reactions, materials |
BIOLOGY |
biology |
Biology | genes, proteins, structures, pathways, variants, diseases |
TAXONOMY |
taxonomy |
Taxonomy | species and the other ranks of the tree of life, and where they were found (occurrences) |
ASTRONOMY |
astronomy |
Astronomy | stars, galaxies, planets, small bodies, space weather events |
EARTH |
earth |
Earth sciences | the solid earth, its oceans and its atmosphere: earthquakes, stations, places |
OTHER |
other |
Other | what none of the above holds: a physical constant, a launch |
Query::$domain narrows a search to one domain; Scheme::domain() gives a scheme's.
Quantity
A number, its unit as the source spells it (g/mol, Ų, °C, mm), and an uncertainty when
given. No conversion is made; scaled($factor, $unit) gives another unit when the caller knows
the factor. (string) new Quantity(194.19, 'g/mol') is 194.19 g/mol; a float below 1e-4 or from
1e9 up is written in scientific notation with every significant digit it holds, its uncertainty in
the same exponent: CODATA's proton mass is 1.67262192595e-27 ± 0.00000000052e-27 kg
(why). The number itself (value) is never rounded.
Series
| Field | |
|---|---|
variable, unit, label |
in the source's names: temperature_2m, °C |
place |
the Point (or BBox) that answered: a model's grid point may not be the point asked |
points |
`list<[\DateTimeImmutable, int |
step |
an ISO 8601 duration: PT1H, PT15M, P1D; null when irregular |
aggregation |
what a point of a step stands for: Series::MEAN, MAX, MIN, SUM; null for an instant value |
forecast |
true for a forecast, false for observed or reanalysed values, null when mixed |
A series iterates over its points (foreach ($series as [$time, $value])) and counts them;
values(), times(), at($time), min(), max(), mean(), between($from, $to).
SeriesQuery(variables, place, from, to, step, aggregation, timezone): the place a Point, a
BBox (Capability::AREA) or a station's Identifier (Capability::STATION, below); both ends of the period
included, a date without a time standing for its whole day; the time zone the times and the days
are given in (auto is the point's own, at Open-Meteo). The variables are the source's names: the
weather's temperature_2m, the air's pm2_5, nitrogen_dioxide, european_aqi (Open-Meteo), the
earthquakes' count (the USGS).
Stations
A station's records are a series of their own: the station that measured them, not the nearest
grid point nor whichever station a source picks. SeriesQuery::$place takes a station's
Identifier in place of a Point - a source with Capability::STATION reads the stations of
its schemes, any other refuses a station before any call -, and the series answered carry the
station's Point (its elevation too):
| Scheme | A station of | Normalised as |
|---|---|---|
ghcn |
the Global Historical Climatology Network (NCEI's GHCN-Daily and its monthly and yearly summaries, the whole world) | eleven characters, upper case: the country (FIPS), the network, the station's own (USW00094728, Central Park; FR000007190, Strasbourg-Entzheim; BR00A6-0010) |
wigos |
the WMO's register (OSCAR/Surface): any observing station | its WIGOS id, 0-20000-0-07190; a WMO index of five digits, written wmo:07190, is read as its WIGOS id (Wikidata's P4136) |
nws.station |
the U.S. National Weather Service's observation stations | upper case: an ICAO airport (KNYC), a buoy (44069), a network's (A4080) |
usgs.site |
the USGS's water monitoring locations (streamgages, wells, springs; the Water Data APIs) | USGS-01646500: the agency's code then eight to fifteen digits; a number alone given the scheme (usgs.site:01646500) is the USGS's; its page and its record in the API read |
coops.station |
NOAA CO-OPS's water level and tide stations (Tides and Currents) | seven digits (8518750, The Battery); coops: read; its page and its record in the metadata API read |
dwd.station |
the Deutscher Wetterdienst's stations | five characters (01420, Frankfurt/Main; P0291), digits padded with zeros (dwd:1420); the DWD has no page for a station |
Only the WIGOS id and a USGS site (USGS-01646500) are told by their shape alone; a GHCN, an
NWS, a CO-OPS or a DWD station needs its scheme (ghcn:USW00094728, nws.station:KNYC,
coops:8518750, dwd:01420) or its page, as its shape is any code's.
$series = $noaa->series(new SeriesQuery(['TMAX', 'PRCP'], Identifier::of(Scheme::GHCN, 'USW00094728'), new \DateTimeImmutable('2026-10-01'), new \DateTimeImmutable('2026-10-07'), SeriesQuery::DAILY));
$series[0]->place; // 40.77898,-73.96925 (42.7 m)
$series[0]->label; // NY CITY CENTRAL PARK, NY US (daily-summaries:USW00094728)
$station = $noaa->entity('ghcn:USW00094728'); // the station itself, an Entity of kind "station"
$station->place; // its Point and elevation
$station->value('period_start'); // the first day it has records of
$station->properties('variable'); // each element and the period it covers: "TMAX: 1869-01-01/2026-10-10"
(see omnilab/noaa for what each API answers)
Occurrences
No model of their own: occurrences(OccurrenceQuery) answers a Page of Entity, each with its
time and its place. OccurrenceQuery(place, from, to, filters, radius, limit, cursor, sort):
a BBox, or a Point and a radius in kilometres; the period; the filters of a domain by key
(OccurrenceQuery::TAXON, COUNTRY, KIND, MIN_MAGNITUDE, MAX_MAGNITUDE, or a source's own);
filter($key) reads one. See occurrences.
Product
A STAC item: id, collection, datetime (and start, end for an interval), bbox and the
GeoJSON geometry of its footprint, properties as the catalogue names them (eo:cloud_cover,
platform, sat:relative_orbit, grid:code...), assets by name, preview (an open thumbnail),
url (the item itself), source. cloudCover(), platform(), asset($name),
assets($role).
An Asset has its href as given (an s3:// or an https:// address), its type, roles
(data, visual, thumbnail, overview, metadata), title, size, and its alternates by
name; httpsHref() gives the first https:// one. Whether it can be read without a token is the
service's business (its README says).
ProductQuery(collections, place, from, to, maxCloudCover, limit, cursor, sort).
Reading a STAC API: Source\Stac
The catalogues that speak the STAC API (the Copernicus Data Space's, NASA's CMR-STAC, Planetary
Computer's, Element 84's Earth Search...) are read the same way, so the core reads them once:
Stac writes a ProductQuery as an item search's parameters and reads its answer back into
Products. A source still makes the call itself (its own getJson(), throttle and errors).
Stac::parameters($query, $collections, $maxPerPage, $cloud) |
collections, bbox (a BBox) or intersects (a Point, as GeoJSON), datetime (RFC 3339 in UTC, .. for an open end), limit (capped), sortby (-datetime, or datetime for the oldest first: the + some catalogues refuse is left out), the cloud cover in the catalogue's dialect: Stac::CQL2 (filter=eo:cloud_cover<=30&filter-lang=cql2-text, the Filter extension) or Stac::QUERY (query[eo:cloud_cover][lte]=30, the Query extension) |
Stac::page($answer, $source, $cursor, $asked) |
a Page: the items as products, next the value of the next link's $cursor parameter (token at the Data Space, cursor at the CMR) - null on a short page -, total from numberMatched or the Context extension's matched |
Stac::item($item, $source) |
a Product: its properties but the catalogue's private ones (_...), its assets with their alternates (the Alternate Assets extension, or the CMR's s3_ twins), file:size, the first open thumbnail as preview, the self link as url |
Stac::collection($collection, $source) |
a collection as a Product (a dataset): its id as collection, its extent's box and interval, title, description, keywords, licence, DOI, providers, assets |
use Omnilab\Model\Point;
use Omnilab\Source\ProductQuery;
use Omnilab\Source\Stac;
use Symfony\Component\HttpClient\HttpClient;
$query = new ProductQuery(['HLSS30_2.0'], new Point(48.5734, 7.7521), new \DateTimeImmutable('2026-10-01T00:00:00Z'), new \DateTimeImmutable('2026-10-14T23:59:59Z'), maxCloudCover: 30, limit: 1);
$answer = HttpClient::create()->request('GET', 'https://cmr.earthdata.nasa.gov/stac/LPCLOUD/search', ['query' => Stac::parameters($query, $query->collections, 50, Stac::QUERY)])->toArray();
$page = Stac::page($answer, 'earthdata', 'cursor', $query->limit);
printf("%d matched; %s, %s %% cloud, B04 at %s\n", $page->total, $page->first()->id, $page->first()->cloudCover(), $page->first()->asset('B04')->alternates['s3']);
2 matched; HLS.S30.T32ULU.2026278T102859.v2.0, 12 % cloud, B04 at s3://lp-prod-protected/HLSS30.020/HLS.S30.T32ULU.2026278T102859.v2.0/HLS.S30.T32ULU.2026278T102859.v2.0.B04.tif
(as answered on 2026-10-15). omnilab/copernicus reads its catalogue so; omnilab/earthdata reads
the CMR's STAC when configured to (stac: LPCLOUD, one archive a catalogue) - it keeps the CMR's
own search by default, which answers every archive at once and was faster (3.5 to 6.5 s against 4
to 16.6 s for the same query, three times each, that day).
Places
Point(latitude, longitude, ?elevation) in WGS 84 degrees and metres; Point::parse('48.57,7.75'),
around($km) gives a box, toGeoJson() writes longitude first. BBox(west, south, east, north)
in the order of GeoJSON and STAC; a box across the antimeridian has its west greater than its
east; BBox::parse(), fromArray() (a 3D bbox too), contains(), center(), toGeoJson().
Identifier
Identifier::parse('CHEBI:27732'); // chebi:27732
Identifier::parse('58-08-2'); // cas:58-08-2 (check digit checked)
Identifier::parse('RYYVLZVUVIJVGH-UHFFFAOYSA-N'); // inchikey:RYYVLZVUVIJVGH-UHFFFAOYSA-N
Identifier::parse('https://www.uniprot.org/uniprotkb/P69905/entry'); // uniprot:P69905
Identifier::parse('https://identifiers.org/ncbigene:7157'); // ncbi.gene:7157
Identifier::parse('ncbigene:7157'); // identifiers.org's prefixes are read
Identifier::of(Scheme::GBIF, '4CGXP')->url(); // https://www.gbif.org/taxon/4CGXP
Identifier::of(Scheme::GBIF, '5219404')->url(); // https://www.gbif.org/species/5219404 (a legacy key)
Identifier::tryOf('cas', '58-08-3'); // null: wrong check digit
| Scheme | Domain | Normalised as |
|---|---|---|
pubchem.cid |
chemistry | the number (CID 2519 read) |
chebi |
chemistry | the number, without CHEBI: (display() gives it back) |
chembl |
chemistry | CHEMBL113 |
inchikey, inchi |
chemistry | upper case; the InChI as written |
cas |
chemistry | 58-08-2, check digit checked |
unii, dsstox, kegg |
chemistry | 3G6A5W338E (UNII- read), DTXSID..., C00001 / D00528 |
kegg.reaction, kegg.pathway |
chemistry, biology | a KEGG reaction R00200 (rn: read); a KEGG pathway map, lower case: map00010, an organism's hsa00010, its KO, enzyme or reaction view ko00010, ec00010, rn00010 (path: read); KEGG's entry pages read for both and for an enzyme (ec:2.7.1.40) |
rhea |
chemistry | a Rhea reaction, its number: 18157 (RHEA:18157 read and given back by display()); any of a reaction's four ids (the master, left to right, right to left, both ways); its page rhea-db.org/rhea/ read |
ec |
biology | an EC number of the IUBMB's Enzyme Nomenclature, four levels, the last ones - when incomplete, n before a preliminary one (2.7.1.40, 2.7.1.-, 3.5.1.n3); EC 2.7.1.40 read by its shape, EC:, ec-code: by their prefix (display() gives EC 2.7.1.40); ExPASy's ENZYME page, the ExplorEnz page and KEGG's read |
cod, materialsproject, oqmd |
chemistry | a crystal structure of the Crystallography Open Database, seven digits (1000000; its page and its CIF read; Wikidata's P9824); a Materials Project material mp-149, lower case, read by its shape and its page (Wikidata's P9902); an OQMD entry, its number, its page oqmd.org/materials/entry/ read |
codata |
other | a fundamental physical constant of CODATA by NIST's short name, case kept: mp (the proton mass), c, Kj; NIST's page physics.nist.gov/cgi-bin/cuu/Value?mp read; Wikidata's P1645 |
uniprot, ncbi.gene, ensembl, pdb |
biology | upper case; Ensembl without its version (ENS... ids only: Ensembl Genomes' are their communities'); a PDB code of four characters, or its extended form pdb_00004hhb, kept lower case |
ncbi.taxon, inaturalist |
taxonomy | the number (txid read for NCBI) |
gbif.occurrence |
taxonomy | the number of a GBIF occurrence (5007681734), its page gbif.org/occurrence/ read |
inaturalist.observation |
taxonomy | the number of an iNaturalist observation (332912788), its page inaturalist.org/observations/ read; a GBIF occurrence published by iNaturalist carries it |
worms |
taxonomy | a WoRMS AphiaID (137094, Delphinus delphis), the taxon OBIS keys its records by; aphia:, the LSID urn:lsid:marinespecies.org:taxname:137094 (read by its shape) and its page marinespecies.org/aphia.php?p=taxdetails&id= read |
obis.occurrence |
taxonomy | an OBIS occurrence by its UUID (00007235-5211-4633-98bb-0f8f9ad53e0f), lower case; its API address read (OBIS has no page for a record) |
itis |
taxonomy | an ITIS TSN (180438); tsn:, the LSID urn:lsid:itis.gov:itis_tsn:180438 (read by its shape) and its report page read |
col |
taxonomy | a Catalogue of Life id in ChecklistBank (4CGXP, Panthera leo): stable from one release to the next, and the key GBIF gives the same taxon since June 2026 - never read by its shape, which a GBIF key shares; catalogueoflife.org/data/taxon/ and checklistbank.org/.../taxon/ read |
pbdb |
taxonomy | a Paleobiology Database taxon number (54833, Tyrannosaurus rex), txn:54833 as its API writes it |
pbdb.occurrence |
taxonomy | a Paleobiology Database occurrence (a fossil in a collection), occ:139292 |
ebird |
taxonomy | an eBird species code (amerob, American Robin), lower case; ebird.org/species/ read |
ebird.checklist |
taxonomy | an eBird checklist (S123456789); ebird.org/checklist/ read |
iucn |
taxonomy | an IUCN Red List taxon by its SIS id (134817215, Delphinus delphis); iucnredlist.org/species/ read |
gbif |
taxonomy | the key of the Catalogue of Life eXtended Release, GBIF's taxonomy since June 2026, upper case (4CGXP: one to six of the digits 2 to 9 and the consonants, Wikidata's P14607); or the number of the legacy GBIF Backbone (5219404, P846). A key of digits alone is read as a legacy number |
ensembl.genomes |
biology | an Ensembl gene, transcript or protein of a genome Ensembl annotates under its community's ids, as Ensembl and Ensembl Genomes serve them on rest.ensembl.org: FBgn0000008 (the fly, FlyBase), WBGene00000001 (the worm), YAL001C (the budding yeast, SGD), AT5G10140 (Arabidopsis, Araport), Os01g0100100 (rice), Zm00001eb000010 (maize), TraesCS1A02G000100 (wheat), SPAC1002.01 (fission yeast, PomBase), PF3D7_0100100 (Plasmodium), AGAP000002 (Anopheles); told by its shape for these ten genomes, each lookup checked on 2026-10-15 (Identifier::ensemblSpecies() names the genome: a lookup without it took 7 to 23 s, or failed), any other with its scheme (ensembl.genomes:...); identifiers.org's ensembl.plant, ensembl.fungi, ensembl.metazoa, ensembl.protist, ensembl.bacteria read |
reactome |
biology | a Reactome stable id, upper case, its version dropped: R-HSA-70171 (a species' three letters, ALL for a small molecule), its page reactome.org/content/detail/ read; Wikidata's P3937 |
string |
biology | a protein of STRING by its STRING id, as written: the NCBI taxon, a dot, the protein's id in its genome (9606.ENSP00000269305, 511145.b0001); read by its shape (a taxon of three digits or more, an id with a letter) and from its page string-db.org/network/ |
interpro, pfam |
biology | an InterPro entry IPR000971, a Pfam family PF00042 (its version dropped), upper case, read by their shapes and from their pages in InterPro |
go, efo, mondo, hp |
biology | a term of the Gene Ontology, the Experimental Factor Ontology, the Mondo Disease Ontology, the Human Phenotype Ontology: its seven digits (display() gives GO:0005829, MONDO:0011382 back); GO:0005829 read by its prefix, the OBO short form MONDO_0011382 (Open Targets', the GWAS Catalog's) by its shape; QuickGO's, OLS's and Monarch's pages read |
hgnc |
biology | a human gene of the HGNC, its number (HGNC:4827 read, display() gives it back) |
omim |
biology | a MIM number, six digits (OMIM:141900, MIM:#603903 read; display() gives OMIM:141900): a gene or a phenotype; a bare one needs its scheme |
orphanet |
biology | an ORPHAcode (ORPHA:232, Orphanet_232 read; display() gives ORPHA:232) |
clinvar |
biology | a ClinVar variation by its VariationID (15333; VCV000015333, versioned or not, read by its shape; display() gives the VCV accession) |
dbsnp, gwas |
biology | a reference SNP rs334, lower case; a GWAS Catalog study GCST000854; both read by their shapes |
gaia |
astronomy | a source of Gaia DR3 by its source_id, a 64-bit number: 4472832130942575872; Gaia DR3 4472832130942575872 read by its shape; Wikidata's P13228 |
exoplanet |
astronomy | a planet or a system of the NASA Exoplanet Archive by the archive's name, spaces collapsed: 51 Peg b, Proxima Cen; its overview page read; Wikidata's P5667 |
simbad, jpl.sbdb |
astronomy | the name, spaces collapsed: M 31, 433, 1P/Halley |
mpc |
astronomy | a minor planet, comet or natural satellite as the Minor Planet Center designates it, spaces collapsed: its number (433; (433) read so), else its designation (2024 YR4, 1P, C/2020 F3); never read by its shape (JPL's are the same); its page minorplanetcenter.net/db_search/show_object read; Wikidata's P5736 |
ned |
astronomy | an object of NED by its preferred name, spaces collapsed: Messier 031, 3C 273; its page ned.ipac.caltech.edu/byname?objname= read |
donki |
astronomy | a space weather event of NASA's DONKI by its activity id, its start then its kind and number: 2026-10-04T06:13:00-FLR-001, read by its shape; no page by that id (DONKI numbers its pages itself) |
launchlibrary |
other | a launch of The Space Devs' Launch Library 2 by its UUID, lower case (launch: read); never read by its shape (OBIS's records are UUIDs too); its API record read and given |
usgs.event |
earth | lower case, a network's code then the event's: us6000u24c, official20110311054624120_30 (the catalogues ComCat keeps, as Wikidata's P3196 names some) |
ghcn, wigos, nws.station |
earth | stations (above): USW00094728; 0-20000-0-07190 (wmo:07190 read); KNYC |
usgs.site, coops.station, dwd.station |
earth | stations (above): USGS-01646500 (read by its shape); 8518750; 01420 |
emsc.event |
earth | an event of the EMSC's catalogue by its SeismicPortal id, the day then a number: 20261004_0000156, read by its shape; its SeismicPortal page read |
wikidata |
any | Q60235 |
parse() reads scheme:value (the schemes' names, identifiers.org's prefixes, CHEBI:), the
pages of each base and identifiers.org's links, and the values whose shape tells their scheme
(an InChIKey, a CAS number, CHEMBL..., DTXSID..., UNII-..., an Ensembl id, a Wikidata
Q-number, a WIGOS id, a Reactome stable id, a Gaia DR3 designation, an InterPro or Pfam accession,
an OBO term's short form, a VCV accession, an rs id, a GCST study, a STRING protein, an EC number written EC 2.7.1.40, a Materials Project id). A bare number, a UniProt accession or a PDB code needs its scheme: other values share
their shapes. An entity may carry two identifiers of one scheme (GBIF's new key and its legacy
number, a secondary accession): the Merger knows it by either. Identifiers keeps each identifier once: get(), value(), values(), has(),
with(), merge(), toArray() / fromArray() for storage.
The methods of Identifier
An Identifier (readonly, Stringable) is a scheme (Scheme) and a normalised value
(string); its constructor is private: it is made by one of these, so that every value is valid
for its scheme.
| Method | |
|---|---|
Identifier::of(Scheme|string $scheme, string $value) |
the identifier; \InvalidArgumentException when the scheme is unknown ("x" is not a scheme Omnilab knows.) or the value is not one of it ("58-08-3" is not a valid CAS number.) |
Identifier::tryOf(Scheme|string|null $scheme, int|string|null $value) |
the same, null in place of the exception (a null scheme or value included) |
Identifier::parse(string $input) |
scheme:value, a base's page, an identifiers.org link, or a value whose shape tells its scheme (above); null when nothing is recognised |
Identifier::scheme(string $name) |
the Scheme a name or prefix stands for (chebi, pubchem.cid, ncbigene, CHEBI...); null otherwise |
Identifier::cid(), chebi(), inchikey(), cas(), wikidata() |
shortcuts for of() with that scheme |
Identifier::ensemblSpecies(Identifier|string $id) |
the genome (Ensembl's production name) an ensembl.genomes id's shape tells: drosophila_melanogaster for FBgn0000008; null otherwise |
key() |
chebi:27732: what the Merger and Identifiers key on; also (string) $identifier |
display() |
as its base writes it: CHEBI:27732, CID 2519, GO:0005829, HGNC:4827, OMIM:141900, EFO:, MONDO:, ORPHA:232, HP:, VCV000015333, RHEA:18157, EC 2.7.1.40; the value as it is for every other scheme |
url() |
the page of the base that holds it; null when the base has none (a DWD station, an OBIS record's has its API address) |
equals(Identifier $other) |
same scheme, same value |
Identifiers (readonly, iterable, countable) keeps each identifier once, in the order learnt
(a null given to it, an identifier that did not parse, is skipped):
| Method | |
|---|---|
new Identifiers(iterable $identifiers), Identifiers::of(?Identifier ...) |
from a list |
get(Scheme), value(Scheme) |
the first identifier of a scheme, its value; null when none |
all(?Scheme), values(Scheme) |
every identifier, or those of one scheme; their values |
has(Scheme|Identifier) |
one of that scheme, or that very identifier |
with(?Identifier ...), merge(Identifiers) |
a copy with more (each still once) |
keys() |
['chebi:27732', ...] |
toArray(), Identifiers::fromArray() |
values by scheme (['chebi' => ['27732']]) for storage, and back |
The methods of Scheme
Model\Scheme is a string-backed enum of 67 cases (the table above gives every value). Besides
Scheme::from() and tryFrom() on its values:
label(): its name for a reader (PubChem CID,ChEBI,Launch Library 2 launch...), used in the messages of the exceptions;domain(): theDomainof the things it names; null forwikidata, which names anything.
Page
Source\Page (readonly, iterable, countable) is one page of search(), occurrences(),
products(), datasets() or jobs():
| Field or method | Type | |
|---|---|---|
items |
list<Entity|Product> |
the page's answers, in the order the source gives (best match first, newest first...) |
next |
?string |
the cursor of the next page, to pass as the query's cursor; null on the last page |
total |
?int |
how many the whole query matches, when the service counts them; null otherwise |
isLast() |
bool |
next is null |
first() |
Entity|Product|null |
the first item |
count(), foreach |
the items of this page (not total) |
A cursor is the source's own: an offset, a page number, a token. Pass it back unchanged.
The queries
All are readonly with a with([...]) that gives a modified copy
($query->with(['cursor' => $page->next])).
Source\Query, for search() and datasets():
| Field | Type | Default | |
|---|---|---|---|
text |
string |
required | what to look for |
domain |
?Domain |
null | the domain the caller means: a source that spans several (Wikidata) narrows to it, a source of one domain answers an empty page for another |
limit |
int |
25 | answers a page; each source caps it |
cursor |
?string |
null | the Page::$next of the page before |
Source\SeriesQuery, for series():
| Field | Type | Default | |
|---|---|---|---|
variables |
list<string> |
required, not empty | in the source's names |
place |
Point|BBox|Identifier |
required | a box needs Capability::AREA; an identifier must be a station's, of SeriesQuery::STATIONS (ghcn, wigos, nws.station, usgs.site, coops.station, dwd.station) |
from, to |
?\DateTimeInterface |
null | both ends included; a date without a time stands for its whole day; null: the source's default |
step |
string |
SeriesQuery::HOURLY (PT1H) |
SeriesQuery::DAILY (P1D) or another ISO 8601 duration the source knows |
aggregation |
?string |
null | for a step longer than the measure: Series::MEAN, MAX, MIN, SUM |
timezone |
?string |
null | the time zone the times and days are given in; null: the source's default |
Its constructor throws \InvalidArgumentException for no variable (A series query names at least one variable.), an identifier that is no station (A series is asked at a place or a station, not at ...), and a period that ends before it starts.
Source\ProductQuery, for products() and jobs():
| Field | Type | Default | |
|---|---|---|---|
collections |
list<string> |
[] |
the source's collection ids (sentinel-2-l2a); empty: the source's default ones, or all |
place |
Point|BBox|null |
null | a box needs Capability::AREA |
from, to |
?\DateTimeInterface |
null | the period; one that ends before it starts is an \InvalidArgumentException |
maxCloudCover |
?float |
null | percent, included; needs Capability::CLOUD_COVER, refused otherwise |
limit |
int |
25 | products a page; each source caps it |
cursor |
?string |
null | the Page::$next of the page before |
sort |
string |
ProductQuery::NEWEST |
or ProductQuery::OLDEST |
Source\OccurrenceQuery, for occurrences(): its fields, its filter keys;
its limit is 25 by default, its sort null (the source's order).
The contracts
| Interface | Methods | |
|---|---|---|
Source\SourceInterface |
getName(), capabilities(), entity(), search(), series(), products(), occurrences(), datasets(), open() |
every source (Sources) |
Source\JobSourceInterface (extends it) |
submit(string $dataset, array $request): Job, job(string $id): ?Job, jobs(ProductQuery $query = new ProductQuery()): Page |
a source that makes files on request (datasets) |
Source\SourceFactoryInterface |
getName(): string, create(array $options = []): SourceInterface |
what the Registry and the bundle take (Writing a provider) |
Source\SourceFactory and Source\HttpSource are the abstract bases every omnilab/* package
extends; Config is the options a factory fills; Source\Stac reads the STAC APIs (above).
Propose a change docs/models.md
Sources
Every source implements Omnilab\Source\SourceInterface:
| Method | Answers |
|---|---|
getName() |
its factory's name: pubchem, chebi, wikidata, uniprot, ensembl, pdb, inaturalist, simbad, usgs, open-meteo, copernicus... |
capabilities() |
a list of Capability: what it can be asked, what its answers hold |
entity($id) |
an Entity, or null |
search(Query) |
a Page of Entity, the best match first; $page->next is the next cursor |
series(SeriesQuery) |
a list of Series, one per variable asked, in the order asked |
products(ProductQuery) |
a Page of Product, newest first by default |
occurrences(OccurrenceQuery) |
a Page of Entity, each with its time and place: what was found or happened at a place in a period (occurrences) |
datasets(Query) |
a Page of Product, each a dataset of the catalogue, the best match first; its id is what ProductQuery::$collections takes (datasets) |
open(Asset|string) |
a file read with the source's credentials: the HTTP response, streamed (datasets) |
A source that makes files on request implements Source\JobSourceInterface besides: submit($dataset, $request), job($id), jobs(ProductQuery) answer a Model\Job - a Product in the making, its status, progress and file (datasets).
What a source does not do throws NotSupportedException, before any call. HttpSource, the base
of the sources over HTTP, throws it for every operation a source does not override.
Capabilities
| Capability | Says |
|---|---|
entity |
entity() reads one thing by an identifier (or a name, where the source reads names) |
search |
search() finds things by text |
series |
series() serves a variable at a place over a time |
products |
products() lists catalogued products over a place and a time |
occurrences |
occurrences() lists the things found or happened at a place in a period |
structure |
an entity's image is its structure (a 2D depiction of a molecule) |
cross_references |
an entity carries identifiers of other bases: the Collector can follow them |
forecast |
series() answers ahead of now |
historical |
series() answers for the past: observations, an archive, a reanalysis |
area |
series(), products() or occurrences() take a BBox; without it, a box is refused rather than answered for its centre |
datasets |
datasets() finds the catalogue's datasets by text |
jobs |
the source implements JobSourceInterface: requests for a file, submitted and followed |
open |
open() reads a file with the source's credentials |
station |
series() takes a station's Identifier (ghcn, wigos, nws.station, usgs.site, coops.station, dwd.station) in place of a Point, and answers its own records; without it, a station is refused (stations) |
cloud_cover |
products() applies ProductQuery::$maxCloudCover; without it, the query is refused rather than answered unfiltered |
What each one does
| pubchem | chebi | wikidata | open-meteo | copernicus | |
|---|---|---|---|---|---|
| Shape | entity | entity | entity | series | product |
entity() by |
CID, name, InChIKey, InChI, CAS, SMILES, ChEBI, ChEMBL, Wikidata, UNII, DSSTox | ChEBI id (a secondary one too), InChIKey, CAS, KEGG, name | Q-number, label, and the identifier of any scheme below its statements carry (but InChI) | - | - |
search() |
the exact name first, then the names that hold the text as a word | ChEBI's search: an exact match first | by label or alias; with a domain, by text among the items that carry a statement of it | - | - |
series() |
- | - | - | any Open-Meteo variable at a Point: hourly (PT1H), 15 minutes (PT15M), daily (P1D, with mean, max, min, sum); with api: air_quality, the air's (pm2_5, pm10, nitrogen_dioxide, ozone, european_aqi, us_aqi, pollens...), hourly or each day's mean, max, min, sum of the hours |
- |
products() |
- | - | - | - | any collection of the catalogue, at a Point or over a BBox, a period, a cloud cover maximum |
| Units | from PubChem's documentation (g/mol, Da, Ų) | g/mol (average mass), Da | the unit item's symbol (Da, °C, g/cm³...) | as Open-Meteo answers (°C, mm, km/h...; μg/m³, EAQI, USAQI, grains/m³) | as the catalogue names its properties |
| Cross-references | InChIKey, InChI, CAS, ChEBI, ChEMBL, Wikidata, UNII, DSSTox, KEGG | secondary ChEBI ids, InChIKey, InChI, CAS, KEGG (and ChEMBL, PubChem, Wikidata, UniProt where ChEBI keeps them) | PubChem CID, ChEBI, CAS, InChIKey, InChI, ChEMBL, UNII, DSSTox, KEGG, UniProt, NCBI Gene, Ensembl, NCBI Taxonomy, GBIF, iNaturalist, SIMBAD, JPL SBDB, USGS event, Reactome, Gaia, NASA Exoplanet Archive, HGNC, OMIM, Mondo, Orphanet, HPO, EFO, ClinVar, dbSNP, GO, InterPro, Pfam, EC (a gene's or a protein's GO, InterPro, Pfam and EC related), WoRMS, ITIS, COL, PBDB, eBird, IUCN, MPC, USGS water site, CODATA; related: COD and Materials Project structures |
- | - |
| Paging (cursor) | offset over the CIDs found | ChEBI's page number | the search's offset | - | the next link's token |
| Per page, at most | 100 | 100 | 50 | - | 100 |
| uniprot | ncbi | gbif | |
|---|---|---|---|
| Shape | entity | entity | entity, occurrences |
entity() by |
accession (a merged one gives its entry), entry name (HBB_HUMAN) |
GeneID; NCBI taxon id (txid9689); a taxon's scientific or common name |
GBIF key (4CGXP), legacy Backbone number, NCBI taxon id, scientific name (a synonym gives the accepted taxon); an occurrence by its key (gbif.occurrence:5007681734) |
search() |
UniProt's query syntax, page by the Link header's cursor |
Entrez's syntax (esearch): NCBI Gene, or NCBI Taxonomy for Domain::TAXONOMY |
GBIF's search of the checklist's names |
occurrences() |
- | - | GBIF's occurrence search: a BBox or a Point (10 km by default), whole days; taxon, country, kind (basis of record); 300 a page, 100,000 deep; its taxon related |
| Units | aa, Da | - | - |
| Cross-references | NCBI Gene, Ensembl gene | a gene: UniProt (Swiss-Prot), Ensembl; a taxon: GBIF (from its links) | legacy GBIF number, NCBI Taxonomy (ENA's checklist) |
| Related | organism (NCBI taxon), structure (PDB, 20 by default), ligand, cofactor (ChEBI) |
a gene's organism, a taxon's parent |
parent, or a synonym's accepted; an occurrence's taxon (and accepted) |
| Paging (cursor) | UniProt's cursor | the search's offset | the search's offset |
| Per page, at most | 500 | 100 | 1000 |
| chembl | simbad | jpl | usgs | |
|---|---|---|---|---|
| Shape | entity | entity | entity | entity, series, occurrences |
entity() by |
ChEMBL id, InChIKey, name or synonym | any SIMBAD identifier or name (M31, Betelgeuse, HD 39801) |
number, designation, name, SPK-ID | event id (ComCat or another network's) |
search() |
ChEMBL's full-text search; an InChIKey or its first block | by identifier or name | the names that hold the text, the designation that is it | - (no text search at the USGS) |
occurrences() |
- | - | - | the events of a BBox or around a Point (100 km by default) in a period; min_magnitude, max_magnitude, kind (event type), alert; sorted newest, oldest, magnitude; 20,000 a page |
series() |
- | - | - | per step: count, magnitude, depth, at a BBox or around a Point |
| Cross-references | InChIKey, InChI; ChEBI, PubChem, UNII, DSSTox (UniChem) | its own id alone | its SPK-ID (a second jpl.sbdb) |
the other networks' ids for the event |
| Related | the targets of its mechanisms, their proteins (UniProt) | - | - | - |
| Collector path | PubChem -> ChEMBL by the InChIKey | Wikidata -> SIMBAD (P3083) | Wikidata -> JPL (P716) | Wikidata -> USGS (P3196), and back |
| ensembl | pdb | inaturalist | |
|---|---|---|---|
| Shape | entity | entity | entity, occurrences |
entity() by |
Ensembl id (gene, transcript, protein, exon; its version dropped); a community's id of a genome Ensembl serves (ensembl.genomes: FBgn0000008, YAL001C, AT5G10140); a gene's symbol in a species (HBB, mus_musculus/Hbb-bs, drosophila_melanogaster/w) |
PDB id (4HHB, pdb_00004hhb); a removed entry gives its only replacement |
taxon id, a name matched exactly (scientific, synonym, common); an observation by its number (inaturalist.observation:332912788) |
search() |
the genes a symbol names in a species, page by offset | RCSB's full-text search (experimental structures), then GraphQL for the page | iNaturalist's search of its names, page by number |
occurrences() |
- | - | the observations of a BBox (across the antimeridian too) or around a Point (10 km by default) in a period, to the second; taxon, license, quality_grade (research by default); unsorted (latest added first, paged by id, no depth limit) or newest, oldest (10,000 deep); 200 a page |
| Units | bp, aa | Å, kDa | m |
| Cross-references | a gene: NCBI Gene, its Swiss-Prot entries; a protein: its UniProt entries | its own id (and the removed id it was asked by) | none (iNaturalist gives no other base's id) |
| Related | organism (NCBI taxon), a gene's canonical transcript, a transcript's gene and protein, a protein's transcript and gene |
protein (its chains' UniProt), organism, ligand (ChEBI, else PubChem, else InChIKey) |
parent, or an inactive taxon's accepted; an observation's taxon |
| Collector path | NCBI Gene or UniProt -> Ensembl by the Ensembl gene id; Ensembl -> NCBI Gene, UniProt, Wikidata (P594) | none: an entry is taken alone, reached from UniProt's structures; its proteins lead back |
Wikidata -> iNaturalist (P3151), and back; GBIF -> Wikidata -> iNaturalist at depth 2; a GBIF occurrence -> its iNaturalist observation |
| emsc | usgs-water | tides | brightsky | |
|---|---|---|---|---|
| Shape | entity, series, occurrences | entity, search, series | entity, series | entity, series |
entity() by |
SeismicPortal id (20261004_0000156), its page |
a monitoring location (USGS-01646500, its page) |
a CO-OPS station (coops:8518750) |
a DWD station (dwd:01420) or a WMO index (wmo:10637) |
search() |
- (no text search) | the USGS's locations by name (CQL2 LIKE), paged by the API's cursor |
- | - |
series() |
per step: count, magnitude, depth, at a BBox or around a Point (a radius sent in degrees) |
discharge, gage height, water temperature... or any USGS parameter code, as recorded on the step's grid or the USGS's daily statistics; a usgs.site or the nearest of a Point |
water level, verified hourly heights and highs and lows, tide predictions (forecast), water and air temperature; a coops.station or the nearest of a Point; cut to the API's month and year limits |
hourly observations and MOSMIX forecasts (14 variables), or days made from the hours; a Point, a dwd.station or a wigos WMO index |
occurrences() |
the events of a BBox or around a Point (100 km by default); min_magnitude, max_magnitude, magnitude_type; sorted newest, oldest, magnitude; 20,000 a page |
- | - | - |
| Units | km | the API's (ft^3/s, ft, degC...) |
m above the datum (MLLW by default), °C | °C, %, mm, hPa, km/h, °, m, min, kWh/m² |
| Cross-references | none | none | none | a station's DWD and WIGOS ids |
| Collector path | none (no id of another base; Wikidata has no EMSC property) | none | none | none |
| swpc | elevation | openaq | |
|---|---|---|---|
| Shape | series (the aurora at a Point) |
none of the contract's | series |
| Outside the contract | kp(), solarWind(), alerts(), scales(): space weather is at no place on the Earth, which a Series needs (Timeline, Alert of the package) |
at(), profile(): Points with their elevation |
- |
series() |
aurora (%, OVATION's latest nowcast, one point, a forecast) |
- | a pollutant by OpenAQ's name at the nearest location of a Point (25 km at most): hourly or daily averages |
| Tested live | yes | yes | no: a key is required, none was created |
| obis | worms | itis | col | |
|---|---|---|---|---|
| Shape | entity, occurrences | entity, search | entity, search | entity, search |
entity() by |
an AphiaID (worms:137094, its LSID, its WoRMS page) or a name; a record's UUID (obis.occurrence:) |
an AphiaID, a name, or an itis, ncbi.taxon or iucn identifier (AphiaRecordByExternalID) |
a TSN (itis:, tsn:, its LSID) or a name |
a COL id (col:4CGXP), a GBIF key of the new form (the same id), or a name |
search() |
- (OBIS has none) | the names beginning with the text, 50 a page | scientific and common names, its count on the first page | ChecklistBank's search, its total |
occurrences() |
the records of a BBox (across the antimeridian, its east past 180°) or a Point's circle (64 sides, 10 km by default); whole days; taxon, min_depth, max_depth, dataset, red_list; OBIS's order, paged after the last id; 9,999 a page |
- | - | - |
| Units | m, °C, PSU | - | - | - |
| Cross-references | ncbi.taxon; a record iNaturalist published: inaturalist.observation |
itis, ncbi.taxon, iucn (one call each) |
none | gbif (the same id, unless digits alone), and one of each base COL names or links to (inaturalist, worms, itis...) |
| Related | parent, accepted; a record's taxon |
parent, accepted, synonym |
parent, accepted, synonym, child |
parent, accepted, synonym |
| Collector path | into NCBI Taxonomy; joined to WoRMS by the worms id |
into ITIS, NCBI, IUCN; reached from any entity with an ITIS, NCBI or IUCN id, and from Wikidata (P850) | reached from WoRMS, COL and Wikidata (P815) | into GBIF, iNaturalist, ITIS, WoRMS; reached from GBIF by the key and from Wikidata (P10585) |
| pbdb | ebird | iucn | |
|---|---|---|---|
| Shape | entity, search | entity, search, occurrences | entity |
| Outside the contract | fossils(OccurrenceQuery): the fossils of a place and an age, each with its place and no time (an age is in Ma, no date) |
- | assessment(): an assessment by its id |
entity() by |
a taxon (pbdb:, txn:) or a name; a fossil (pbdb.occurrence:, occ:) |
a species code (no key); a name, a checklist (ebird.checklist:) with the key |
a SIS id or a name, with its latest global assessment |
occurrences() |
refused (a fossil has no time) | the latest of each species at each location around a Point (50 km, 30 days back) or in a region, or a region's day; the key |
- |
| Units | Ma | - | - |
| Tested live | yes | the taxonomy; not the rest (a key needed, none created) | no: a token is required, none created |
| gaia | exoplanets | |
|---|---|---|
| Shape | entity | entity |
entity() by |
Gaia DR3 source_id (gaia:4472832130942575872), designation (Gaia DR3 ..., its simbad form too) |
a confirmed planet's name: the archive's (exoplanet:51 Peg b), SIMBAD's (* 51 Peg b), or an alias the archive resolves (Dimidium); a star's name refused |
search() |
- | by planet or star name, paged by name |
| Outside the contract | cone(): the sources of a cone on the sky (the core's places are on the Earth) |
planets(): planets by simple criteria |
| Units | deg, mas, mas/yr, km/s, mag, K, pc, R☉, M☉, Gyr | the archive's (Earth and Jupiter masses and radii, days, au, K) |
| Cross-references | its designation and its Hipparcos best neighbour as SIMBAD names them | its name as SIMBAD reads it (and SIMBAD's main identifier when asked by it) |
| Related | - | host: the star by each designation (name, HD, HIP, TIC, Gaia DR3, the last in the gaia scheme too) |
| Collector path | SIMBAD -> Gaia (the designation), Wikidata -> Gaia (P13228) | Wikidata -> SIMBAD + the archive (P3083, P5667) |
| mpc | horizons | ned | vizier | donki | launches | |
|---|---|---|---|---|---|---|
| Shape | entity, search | series | entity | datasets | entity | entity, search, occurrences |
entity() by |
a number, designation (packed or not) or name; mpc: or jpl.sbdb: |
- | any name NED resolves; ned: or simbad: |
- | an activity id (2026-10-08T15:29:00-FLR-001) |
a launch's UUID (launch:) |
search() |
the names that hold the text, the closest first | - | - | - | - | LL2's search, by offset |
series() |
- | a body seen from a Point: each variable <body>:<quantity> (499:ra, jpl.sbdb:99942:distance); steps from minutes to years; no aggregation |
- | - | - | - |
occurrences() |
- | - | - | - | - | the launches of a period, each at its pad; provider, rocket, location, crewed, status, orbit; newest, oldest; no place (LL2 filters by none) |
datasets() |
- | - | - | the tables whose description holds the text, the largest first, one page | - | - |
| Outside the contract | observations(): the latest optical observations |
- | cone(): the objects of a cone on the sky |
rows(), rowsOf(): a table's rows around a point of the sky or an entity |
events(): every event of some kinds in a period (no place on the Earth) |
- |
| Units | au, deg, d, mag | deg, au, km/s, mag, %, arcsec | deg | each column's | km/s, deg | % |
| Cross-references | jpl.sbdb (the same designation), its primary designation |
- | none | - | none | none |
| Related | - | - | - | - | linked: the events DONKI links it to |
- |
| Collector path | MPC <-> JPL by the designation; Wikidata -> MPC (P5736) | - | SIMBAD -> NED by SIMBAD's name; Wikidata -> SIMBAD -> NED | - | - | - |
| Tested live | yes | yes | yes | yes | yes (at ccmc.gsfc.nasa.gov/DONKI-API: api.nasa.gov/DONKI answers 301 since 30 September 2026) | yes, within 15 calls an hour |
| reactome | |
|---|---|
| Shape | entity |
entity() by |
a pathway's or a reaction's stable id (reactome:R-HSA-70171, versioned or not, its page); a physical entity's id is null |
search() |
Reactome's search of pathways and reactions in a species (Homo sapiens by default), page by its Start row |
| Outside the contract | pathways(): the pathways (or reactions) of a UniProt protein or a ChEBI molecule |
| Units | - |
| Cross-references | none (no other base names a pathway) |
| Related | organism (NCBI taxon), event (sub-pathways and reactions), ortholog (the same event in other species), protein (UniProt), molecule (ChEBI) |
| Collector path | Wikidata -> Reactome (P3937); UniProt -> Reactome by pathways(), outside the Collector |
| kegg | rhea | |
|---|---|---|
| Shape | entity | entity |
entity() by |
a compound or drug (C07481, D00528), a reaction (R00200), a pathway (map00010, hsa00010), an enzyme (2.7.1.40), a KEGG page; a compound by its ChEBI id (KEGG's conv) |
any of a reaction's four Rhea ids (RHEA:18160 gives its master 18157), or its KEGG reaction (kegg.reaction:R00200) |
search() |
find over the configured databases (compound by default), cut into pages |
Rhea's text search, paged by offset |
| Units | g/mol, Da | - |
| Cross-references | CAS, ChEBI, ChEMBL, Rhea, the KEGG entry it is the same as | the three other ids of its quartet, its KEGG reaction |
| Related | reaction, pathway, enzyme (EC), compound, substrate, product, drug, go, a drug's organism |
participant (ChEBI), enzyme (EC), go, event (Reactome), protein (reviewed UniProt) |
| Collector path | KEGG <-> Rhea (each names the other); ChEBI -> KEGG by conv |
KEGG <-> Rhea |
| Terms | academic use by academic users only: not tested live | CC BY 4.0 |
| hgnc | string | interpro | quickgo | |
|---|---|---|---|---|
| Shape | entity | entity | entity | entity |
entity() by |
an HGNC id (HGNC:4827, a bare number), a symbol (approved, then previous, then alias, when one gene alone bears it), an NCBI GeneID, Ensembl gene, UniProt accession or MIM number it records |
a STRING id (9606.ENSP00000269305), a UniProt accession, an Ensembl protein or a name, in a species (9606 by default) |
an InterPro entry (IPR000971), a Pfam family (PF00042), a UniProt protein |
a GO term (GO:0005829, GO_0005829; a secondary id gives its term), a UniProt protein (its annotations) |
search() |
HGNC's search over every field, paged here | none (STRING maps names, it does not rank texts) | InterPro's text search over its entries, by accession, paged by cursor | QuickGO's term search, paged |
| Outside the contract | - | partners(), network() (Interaction: score and evidence channels), enrichment() (Enrichment: GO, KEGG, Reactome, Pfam, InterPro, HPO...) |
- | annotations() (Annotation: qualifier, GO and ECO evidence, reference, curator) |
| Units | - | - | aa | - |
| Cross-references | NCBI Gene, Ensembl gene, UniProt, OMIM | its Ensembl protein; the UniProt accession it was asked by | none | none (a secondary GO id it was asked by) |
| Related | organism |
organism, interactor (STRING ids; each partner's score a property) |
an entry's member (Pfam), integrated, go, structure, overlap, parent, child; a protein's entries by type (domain, family, homologous_superfamily...), go, organism |
a term's parent, one relation each (is_a, part_of...), child; a protein's function, process, component, organism |
| Collector path | HGNC <-> NCBI Gene, Ensembl, UniProt; HGNC -> Open Targets; Wikidata <-> HGNC (P354) | UniProt -> STRING | UniProt -> InterPro | UniProt -> QuickGO |
| opentargets | clinvar | gwas | |
|---|---|---|---|
| Shape | entity | entity | entity |
entity() by |
a target's Ensembl gene, a disease's EFO, Mondo, HPO or Orphanet id (MONDO_0011382, MONDO:0011382), a drug's ChEMBL id |
a VariationID (15333) or VCV accession; an rs id is refused (a site, several variations) |
a study (GCST000854), a variant (rs334), a trait (EFO, Mondo, HPO) |
search() |
Open Targets' search over targets, diseases and drugs, paged | ClinVar's query language (HBB[gene] AND pathogenic[clinsig]), paged |
the catalogue's traits by name, paged |
| Outside the contract | associations(): a target's diseases or a disease's targets, paged (Association: score by data type) |
- | associations(): a study's, a variant's or a trait's, the smallest p-value first (Association: risk allele, p-value as mantissa and exponent, OR or beta, interval) |
| Units | - | - | - |
| Cross-references | a target's Swiss-Prot and HGNC ids; a disease's former ids, OMIM, Orphanet, EFO, Mondo, HPO ids (MeSH, ICD-10, UMLS, NCIt... as properties) | its dbSNP id | none |
| Related | a target's disease, drug, domain (InterPro), pathway (Reactome), organism; a disease's target, drug, parent, therapeutic_area; a drug's target, indication, parent |
gene (NCBI Gene), condition (Mondo, OMIM, Orphanet, HPO) |
a study's trait, background_trait, variant; a variant's study, associated_trait; a trait's study |
| Collector path | HGNC, Ensembl, NCBI Gene -> Open Targets (the Ensembl gene); ChEMBL -> Open Targets | ClinVar -> GWAS Catalog (rs id) | ClinVar -> GWAS Catalog |
| Terms | CC0 | attribution, cite ClinVar | EMBL-EBI's terms of use |
| cod | cactus | codata | |
|---|---|---|---|
| Shape | entity | entity | entity |
entity() by |
a COD id (9008565, its page, its CIF) |
a name, a CAS number, an InChIKey, an InChI, a SMILES | NIST's short name (mp) or a constant's name |
search() |
the entries' metadata (two calls a page) | - | words of the constants' names |
| Outside the contract | cif(): the CIF as an Asset; open() reads it |
- | adjustment(): 2022 |
| Units | Å, °, ų, K, kPa | g/mol | NIST's (kg, m s^-1, J Hz^-1...) |
| Cross-references | none | standard InChI, CAS numbers (the id is the InChIKey) | none |
| Related | duplicate_of (COD) |
- | - |
| Collector path | none: Wikidata keeps a compound's COD structures (P9824) as related things, not followed | CIR -> PubChem, ChEBI, ChEMBL by the InChIKey | Wikidata -> CODATA (P1645) |
| materials-project | oqmd | |
|---|---|---|
| Shape | entity | entity |
entity() by |
an mp id (mp-149, its page) |
an OQMD id (oqmd:16974, its page) |
search() |
a formula (Fe2O3) or a chemical system (Fe-O) |
a composition or a phase space |
| Units | g/cm³, ų, eV/atom, eV, μB, GPa | ų, eV/atom, eV |
| Cross-references | none (other databases' ids as properties) | none (ICSD's as a property) |
| Related | - | duplicate_of (OQMD) |
| Tested live | no: a key is required | no: the service was down on 2026-10-15 |
Their limits and politeness are in each package's docs/index.md.
Errors
Unknown is null; anything else a service refuses is an exception, before or after a call:
| Exception | When |
|---|---|
NotSupportedException |
the source does not do it (an operation, a scheme, a filter, a box), said before any call |
RateLimitedException |
429: slow down (retryAfter when the service says, in seconds) |
UnavailableException |
5xx, a timeout, a host that does not answer |
ProviderException |
any other error answer (4xx), its status and what the service said |
HttpSource maps a call's answer so for every source, and writes into the message what the
service said - the services write it each its own way, all read:
| The answer | Services | The message |
|---|---|---|
{"reason": ...} |
Open-Meteo | the reason |
{"Fault": {"Message", "Details": [...]}} |
PubChem | the message (the details) |
{"title", "detail", "parameterErrors": [...]} (RFC 9457 problem details) |
the CDS and the ADS, the NWS | title: detail (each parameter: its message) |
{"detail": {"message"}}, {"detail": "..."}, {"code", "description"} |
the Copernicus Data Space's STAC | the message, the detail, the description |
{"errorMessage", "errors": [{"field", "message"}]} |
NCEI | the message (each field: its message) |
{"errors": ["..."]}, {"errors": [{"message"}]} |
NASA's CMR, iNaturalist's v2 | the errors |
{"messages": ["..."]} |
UniProt | the messages |
{"message"}, {"error_message"} |
NCBI Datasets, JPL, RCSB's search; ChEMBL | the message |
{"error": "..."}, {"error": {"info"}} |
Ensembl, iNaturalist's v1; Wikidata | the error, its info |
| an HTML page, a VOTable | GBIF's front (Varnish), the EBI's; SIMBAD's TAP | the page's title; the QUERY_STATUS error |
| any other text | the USGS, GBIF | the text, white space collapsed, cut at 300 characters |
A web page answered where JSON was asked, whatever its status, is an UnavailableException that
gives the page's title: a service's maintenance page (the ESA Gaia Archive answered 200 with its
"Gaia (OPER) Downtime Page" on the evening of 2026-10-15).
use Omnilab\Cds\CdsSourceFactory;
use Omnilab\Exception\ProviderException;
use Symfony\Component\HttpClient\HttpClient;
$cds = (new CdsSourceFactory(HttpClient::create()))->create(['key' => 'not-a-key']);
try {
$cds->submit('reanalysis-era5-single-levels', ['variable' => ['2m_temperature']]);
} catch (ProviderException $e) {
echo $e->status, ' ', $e->getMessage(), "\n";
}
401 [cds] the service answered 401: Authentication failed: operation not allowed
(as answered on 2026-10-15; NCEI's {"errorCode":400,"errorMessage":"Bad Request","errors":[{"field":"dataset","message":"Unsupported dataset."}]}
reads Bad Request (dataset: Unsupported dataset.), the CMR's {"errors":["[foo] is not a valid URL encoded bounding_box"]} its error)
What the services do not allow
-
PubChem: five calls a second and 400 a minute at most (the throttle leaves 0.2 s between two); a compound costs up to four calls; synonyms are the depositors', cross-references are read from them (the first of each scheme, in PubChem's order); experimental properties (melting point...) are in PUG-View, not read here; a search by word ranks nothing beyond the exact name.
-
ChEBI: one call for an entry by its ChEBI id, two by anything else (its search first); no PubChem CID nor Wikidata item among caffeine's cross-references (the Wikidata source finds the item by the ChEBI id); the ontology's relations give names, not ids; the search answers 404 for nothing found, and does not answer a page past the last one (the source never asks it); the SOAP service of ChEBI 1 is gone.
-
Wikidata: a User-Agent that names the caller is required (403 otherwise); 200 calls a minute for an unauthenticated client (Wikimedia's limits, new in 2026), in series; an item costs two or three calls and its answer is large (caffeine's, 260 kB); an identifier two items carry names neither; PDB structures (P638) are not taken for cross-references; GBIF's key is read from P14607 (its 2026 taxonomy,
4CGXP) and P846 (the legacy number), few items carrying P14607 yet (2663 on 2026-10-15); the search index follows edits after a delay. -
Open-Meteo: a point only, not a box; the forecast API answers from 92 days ago to 16 days ahead, the archive from 1940 to about five days ago; free for non-commercial use only, within 10 000 calls a day; the point of the model's grid answers, not the exact one asked. Its air-quality API (
api: air_quality, a source of its own) answers hourly from 2013-01-01 to about five days ahead (the CAMS models; a grid point answers: 48.6, 7.8 for Strasbourg), no box, no daily variable (the source makes the days from the hours), and says nothing of whether a past hour is a forecast run's or a reanalysis' (forecastnull). -
Copernicus Data Space: searching is open, downloading an asset needs a CDSE access token (out of this package); the catalogue does not count the matches; at most 100 Sentinel-2 items a page without fields selection; a radar collection has no cloud cover, so a cloud filter matches nothing there.
-
UniProt: no rate published (only the
streamendpoint, not used, answers 429); an entry whole is large (hemoglobin beta: 342 kB), so each call names its fields; asked forgzipalone, UniProt compresses the body twice - the source asks foridentity; an accession merged into another is answered 303 (a client that follows it gets the entry whole, its fields dropped); no Wikidata item among its cross-references, no secondary accessions among the fields. -
NCBI: three calls a second for E-utilities, five for Datasets, ten for both with an API key;
toolandemailasked of every E-utilities client; Datasets has no text search (E-utilities'esearchdoes it, Datasets then reads the page in one call); a taxon's links name GBIF, EOL and Wikipedia, not Wikidata nor iNaturalist; GBIF's page there is a legacy key. -
GBIF: no rate published, 429 when it slows a caller down (its search APIs first); its taxonomy is the Catalogue of Life eXtended Release since June 2026, but a call that names no checklist is still answered from the frozen Backbone - the source names it every time; three calls a taxon (match, record, the ENA usage that gives the NCBI id); no Wikidata nor iNaturalist id; its occurrence search answers 300 a page and stops 100,000 deep (its downloads, which need an account, are not read); a period is whole days; it sorts nothing; a legacy number as a taxon filter is read in the frozen Backbone.
-
ChEMBL: no rate published; a molecule costs up to four calls (the record, UniChem's cross-references, the mechanisms, their targets); its record names no other base but the InChI and its key - the others come from UniChem, which answers only POST, a call the
throttledoes not space; a full-text search for an InChIKey answers unrelated molecules, so a key is matched on the structures instead. -
SIMBAD: blacklists a client beyond about five to ten queries a second (the source stays at five); one ADQL query an object; no object of the solar system (see JPL); a search sorts the matches itself, SIMBAD's
ORDER BYover the join taking minutes; the measurement tables and the bibliography are not read. -
JPL SBDB: one request at a time, as JPL's fair use asks (0.2 s apart); one call a body, every section asked at once and at full precision (without it every value is rounded to three digits); a name two bodies share (
Halley) names neither; the search's regular expression is case-sensitive, so each letter is asked in both cases (4 to 7 s); close approaches and Sentry are read per body, not as lists. -
USGS: no rate published; one call an event, one for a series whatever its variables, one a page of occurrences; at most 20,000 events a query for a series (the service's 400, said as such), 20,000 a page of occurrences, which page through more; no count without a call more (a page has no
total); no text search; no Wikidata item among an event's ids (Wikidata keeps the USGS id in P3196, which omnilab/wikidata reads).events(), its list before the contract had occurrences, is deprecated. -
Ensembl: 15 calls a second and 55,000 an hour; every call must say
Content-Type: application/json, a GET's too (Ensembl answers HTML otherwise); an unknown id or symbol is a 400, read as null; a gene costs four calls (record, species, its cross-references, its Swiss-Prot entries: its own list mixes the reviewed and unreviewed with nothing to tell them apart); no Wikidata item among its cross-references (Wikidata keeps the Ensembl gene id in P594); the genomes of Ensembl Genomes (plants, yeasts, the fly) keep their communities' ids, which are not theensemblscheme. -
RCSB PDB: every API is rate-limited ("a handful of calls a second" to start with, 429 beyond); GraphQL answers its errors with a 200; the Search API answers ids only, and a 204 when nothing matches; a removed entry replaced by several is none of them (null); no Wikidata link (P638 lists a thing's structures, which are not it).
-
Reactome: no rate published; an event costs two calls (itself, its participants: thousands for a top-level pathway,
participants: falseleaves them); the search's paging parameter is namedStart row, with its space (startis ignored: the first page comes back); a search that matches nothing answers 404; the Content Service gives an event's sub-events but not the pathways above it. -
iNaturalist: 100 calls a minute at most, 60 asked, 10,000 a day, and blocks without notice a use that weighs on it; "not data scraping" (its exports and GBIF are for bulk); version 1 answers an observation whole (44 MB a page of 200), so the observations are read in version 2, its fields chosen; paging by number stops 10,000 deep (the unsorted order pages by id instead, without a limit); no identifier of another base for a taxon; each observation and photo keeps its owner's licence, all rights reserved when none.
-
KEGG: its API is "made available only for academic use by academic users"; non-academic use needs a licence (Pathway Solutions), so omnilab/kegg is not tested live; three calls a second at most, or the caller is blocked; flat files, not JSON;
finddoes not page (the source cuts its lines into pages); KEGG's PubChem link is a substance (SID), not a compound. -
Rhea: no rate published; a reaction costs one SPARQL query (about 3 s;
DISTINCTorLIMITin its sub-query take over a minute) and one UniProt search for its reviewed proteins (enzymes: 0leaves it); Rhea's search gives no total (itscountis the page's); its pages answer a browser check to a program. -
STRING: one call a second (its request), and
caller_identityon every call; two calls an entity (the mapping, the partners);get_string_idsmaps a name to its best protein but does not rank texts (nosearch()); a long annotation is cut by STRING ([...]); its pages answer a browser check to a program. -
InterPro: no rate published; "nothing found" is a 204 that carries a
Content-Length, which curl refuses over HTTP/2 - the source speaks HTTP/1.1; its text search is not ranked (by accession); a protein's entries are one page of 200. -
QuickGO: no rate published; a term's full record (
/complete) is mostly its history (198 kB for the cytosol): the source asks the term and its paths to its aspect's root (its direct parents are their first steps); a protein's annotations come 200 a page, five pages at most by default. -
HGNC: ten calls a second (the source waits 0.1 s); JSON only when asked by
Accept; human genes only; a symbol two genes share (an alias) names neither; its search answers every match at once. -
Open Targets: no rate published; one GraphQL call an entity; a malformed query is a 400 with its messages; a drug's stage for a target is that pair's (voxelotor: withdrawal for HBB, approval as a drug); its PDB and DrugBank cross-references are left out.
-
ClinVar: the E-utilities' rules -
toolandemailon every call, three calls a second without a key, ten with one, per address (two 429s came on 2026-10-15 while other clients shared it: the source makes such a call again once, after the wait NCBI asks); an rs id names a site (rs334: two alleles and eight haplotypes), not a variation. -
GWAS Catalog: the v1 API is retired (410); v2 has no rate published; a p-value is kept as its mantissa and exponent (
1e-323: a float underflows past about 1e-308);studies?disease_trait=matched nothing for "sickle" on 2026-10-15: the search goes through the traits. -
COD: no rate published; a slow server (0.3 to 7.6 s a call) that answered 403 to eight calls in ten seconds (0.5 s between two calls by default) and dropped a few connections; a search costs two calls (the ids, then the page's rows), its total is the number of ids; an id names an entry whatever COD flags it (duplicates, errors, theoretical: asked with
include_*=1). -
CIR (cactus): no rate published; one call a representation (eight an entity,
representationsandnameslessen them); a structure's slashes must stay raw in the path (%2Fis a 404); a name it cannot read is a 500 "Page not found" in text, an empty XML answer: null; it reads systematic names with OPSIN itself, so OPSIN's web service (EBI) is asked only when CIR fails. -
CODATA (NIST): two files in all (the text table of the values, the list that names each constant), kept for the source's life; the table abbreviates names the list writes in full (
mag. mom.,gyromag.), matched; a constant is ofDomain::OTHER(the core has no physics); a float keeps fifteen to seventeen digits,value_textevery digit NIST gives. -
Materials Project: an account's key in
X-API-KEY, required (401 without: omnilab/materials-project is not tested live, no account having been created); ids asked in their legacy form (id_format=legacy:mp-149, not the AlphaIDmp-aaaaaaft); 1000 materials a page at most. -
EMSC: no rate published; 20,000 events a query at most (a 413 "More than 20000 entries" without a limit, a larger limit cut); an end given as a date alone is read as its whole day (the source always sends a time); a radius in degrees; no event-type filter (a 400); the geometry's depth changes sign with the event's age (the property is read). EarthScope's (IRIS) FDSN event service is retired (404 "This service has been retired", checked on 2026-10-15): not offered.
-
USGS Water Data: the WaterServices (
waterservices.usgs.gov) shut down on 2027-02-22, the OGC APIs replace them; 50,000 features a call at most (a 400 beyond), the next page by thenextlink; no order promised (the points are sorted); rate limits per key or per address, a 429 beyond, higher with an api.data.gov key. -
NOAA CO-OPS: a month of six-minute values a request, a year of hourly ones or of highs and lows (the source cuts longer periods); errors come as
{"error": {"message": ...}}, often with a 200; "No data was found" (a product a station lacks, verified data not yet published) is a series without values; the API asks its callers to space their calls (0.5 s by default). -
NOAA SWPC: public files, the latest only (the Kp index of the last week and the next three days, a day of solar wind, the last aurora nowcast); the older
products/solar-wind/*-7-day.jsonanswer 404. -
Bright Sky: observations dense in Germany, the forecasts sparse elsewhere; records from 2010; an unknown station or a point without records is a 404; the DWD's data under CC BY 4.0, the source acknowledged.
-
Open Topo Data: the public instance takes 100 locations a call, a call a second, 1,000 calls a day; a dataset answers null outside its extent.
-
OpenAQ: a key required (401 without, met on 2026-10-15), 60 calls a minute and 2,000 an hour per key; omnilab/openaq is not tested live, no key having been created.
-
OBIS: no rate published; 10,000 records a page at most (a 400 beyond: "Size 20000 too large"); dates as days only (a time: a 500, "Invalid date format"); its statistics take up to 8 s for a kingdom.
-
WoRMS: no rate published; one to three seconds a call, and once that evening a synonyms call answered 500 after 31 s (what fails besides the record is left out and said in
missing); 204 for nothing; one external id type a call. -
ITIS: no rate published; its JSON in ISO-8859-1 (read as such); an unknown TSN an empty 200.
-
Catalogue of Life (ChecklistBank): no rate published; a release a month (3LXR, the latest XR); GBIF runs one release (315557, COL26.6 XR) behind it, so a newer taxon is in COL, not yet in GBIF.
-
PBDB: no rate published; its
max_ma/min_maorders answered 400 on 2026-10-16 though listed, and its wildcard name list left Tyrannosaurus out of "Tyranno%": the source searches through the autocompletion. -
eBird: a key for everything but the taxonomy (403 without one, met on 2026-10-16); recent observations within 50 km and 30 days, the latest of each species at each location; times in local time without an offset; omnilab/ebird is not tested live beyond the taxonomy.
-
IUCN Red List: a token for every call (403 without one, met on 2026-10-16); half a second between calls asked; non-commercial use under its terms of use; omnilab/iucn is not tested live.
-
Minor Planet Center: no rate published; its APIs take a JSON body on a GET (a POST answers 405:
HttpSource::getForJson()); two calls an entity; a name two bodies share (Halley) names neither; every observation of a body comes at once (Eros: 18,679 in 1.7 MB, 5 to 15 s). -
JPL Horizons: one request at a time, as JPL asks (0.2 s apart); its errors come with a 200 in an
errorfield; a table holds 90,024 lines at most; a plain name is Horizons' text search over the major bodies (Erosgave Kerberos): name a small body in thejpl.sbdbscheme. -
NED: no rate published; its directory's name column is not indexed (a query by name ran past a minute), so a row is read by a cone of an arcsecond; no
OFFSETin its ADQL (a cone is one page); the distances themselves are web pages only, not read. -
VizieR: no rate published for TAP; no
OFFSET,ILIKEnorLOWER()in its ADQL (a search is one page, each word asked in four cases); a table's positions are at its own epoch. -
DONKI: no key and no rate published since its move to ccmc.gsfc.nasa.gov/DONKI-API on 30 September 2026 (the old address and api.nasa.gov/DONKI answer 301); one call per kind of event; no paging.
-
Launch Library 2: 15 calls an hour without a key (429 beyond), a supporter's key for more;
lldev.thespacedevs.com(stale data, no limit) for development; no geographic filter. -
OQMD: no rate published; down on 2026-10-15 (502, then time-outs), so omnilab/oqmd is not tested live; one entry is asked by
filter=entry_id=..., which its documentation does not list among the filter's keywords.
Throttling and politeness
| Source | Default throttle |
Contact |
|---|---|---|
| pubchem | 0.2 s (PubChem's five a second) | mailto in the User-Agent |
| chebi | none (no rate published) | mailto in the User-Agent |
| wikidata | 0.3 s (Wikimedia's 200 a minute) | a User-Agent with mailto or the project's URL: required |
| open-meteo | none (weather and air quality) | User-Agent; api_key for a commercial plan |
| copernicus | none | User-Agent |
| uniprot | none (no rate published) | mailto in the User-Agent |
| ncbi | 0.34 s without a key, 0.1 s with one (auto) |
tool and email (from mailto) on E-utilities; api_key |
| gbif | none (no rate published) | mailto in the User-Agent, as GBIF asks |
| chembl | none (no rate published; UniChem's POST is not spaced) | mailto in the User-Agent |
| simbad | 0.2 s (SIMBAD blacklists beyond five to ten a second) | mailto in the User-Agent |
| gaia | none (no rate published; an anonymous query has 10 s) | mailto in the User-Agent |
| exoplanets | none (no rate published) | mailto in the User-Agent |
| jpl | 0.2 s (one request at a time, as JPL asks) | mailto in the User-Agent, as JPL asks |
| usgs | none (no rate published) | mailto in the User-Agent |
| ensembl | 0.07 s (Ensembl's 15 a second) | mailto in the User-Agent; Content-Type: application/json |
| pdb | 0.25 s (RCSB's "a handful a second") | mailto in the User-Agent |
| reactome | none (no rate published) | mailto in the User-Agent |
| inaturalist | 1 s (iNaturalist's 60 a minute) | mailto in the User-Agent |
| kegg | 0.34 s (KEGG's three a second) | mailto in the User-Agent |
| rhea | none (no rate published) | mailto in the User-Agent, as Rhea asks |
| string | 1 s (STRING's one a second) | caller_identity on every call; mailto in the User-Agent |
| interpro | none (no rate published) | mailto in the User-Agent |
| quickgo | none (no rate published) | mailto in the User-Agent |
| hgnc | 0.1 s (HGNC's ten a second) | mailto in the User-Agent |
| opentargets | none (no rate published) | mailto in the User-Agent |
| clinvar | 0.34 s without a key, 0.1 s with one (auto) |
tool and email (from mailto) on every call; api_key |
| gwas | none (no rate published) | mailto in the User-Agent |
| cod | 0.5 s (COD answered 403 to eight calls in ten seconds) | mailto in the User-Agent |
| cactus | none (no rate published) | mailto in the User-Agent |
| codata | none (two calls in all) | mailto in the User-Agent |
| materials-project | none (no rate published) | mailto in the User-Agent; heavy use announced to the Materials Project |
| oqmd | none (no rate published) | mailto in the User-Agent |
| emsc | none (no rate published) | mailto in the User-Agent |
| usgs-water | none (rate limits per key or address) | mailto in the User-Agent; api_key as X-Api-Key |
| tides | 0.5 s (CO-OPS asks its callers to space their calls) | application=omnilab on every call; mailto in the User-Agent |
| swpc | none (no rate published) | mailto in the User-Agent |
| brightsky | none (no rate published) | mailto in the User-Agent |
| elevation | 1 s (the public instance's one a second) | mailto in the User-Agent |
| openaq | 1 s (60 a minute per key) | the key as X-API-Key; mailto in the User-Agent |
| obis | none (no rate published) | mailto in the User-Agent |
| worms | none (no rate published) | mailto in the User-Agent |
| itis | none (no rate published) | mailto in the User-Agent |
| col | none (no rate published) | mailto in the User-Agent |
| pbdb | none (no rate published) | mailto in the User-Agent |
| ebird | none | the user's key as X-eBirdApiToken; mailto in the User-Agent |
| iucn | 0.5 s (the IUCN's half second) | the user's token in Authorization; mailto in the User-Agent |
| mpc | none (no rate published) | mailto in the User-Agent |
| horizons | 0.2 s (one request at a time, as JPL asks) | mailto in the User-Agent, as JPL asks |
| ned | none (no rate published) | mailto in the User-Agent |
| vizier | none (no rate published) | mailto in the User-Agent |
| donki | none (no rate published) | mailto in the User-Agent |
| launches | none (15 calls an hour without a key: the limit is the caller's to keep) | api_key as Authorization: Token; mailto in the User-Agent |
Every factory takes mailto, user_agent, throttle (seconds between two calls of one source
instance) and base_uri.
Writing a source
A source is a package omnilab/<name> whose factory extends Source\SourceFactory (its name, its
options and their defaults in populate(), the source in build()) and whose source extends
Source\HttpSource, overriding the operations it does and declaring them in capabilities(). It
requires omnisense/omnilab and symfony/http-client, and accepts the client given to its factory.
Its identifiers are those of Model\Scheme: a base without a scheme yet adds one to the core
first. What a source reads it reads from an Identifier of one of its schemes, and it throws
NotSupportedException::identifier() for any other before calling anything: the Collector
relies on it.
Writing a provider takes these rules step by step: the package, the factory, the
HttpSource helpers, a new scheme, where a source is registered, its tests and its page.
Propose a change docs/sources.md
Occurrences: the things of a place and a period
The fourth shape. The three others answer "what is this thing" (an entity), "how does this variable
go at this place" (a series) and "what was catalogued over this area" (products); this one answers
"what was found, or happened, here, then": the lions observed within 100 km of Skukuza in
2025, the earthquakes of magnitude 4 or more over Europe in the first week of October 2026. Each
answer is an Entity - with its identifier, its properties and units, its related things - that
carries its time and its place.
$page = $source->occurrences(new OccurrenceQuery(
place: new Point(-24.99, 31.59), // or a BBox; none: anywhere
from: new \DateTimeImmutable('2025-01-01'), // both ends included; a date alone is its whole day
to: new \DateTimeImmutable('2025-12-31'),
filters: [OccurrenceQuery::TAXON => '4CGXP'], // what only one domain filters on, by key
radius: 100, // km around the Point; null: the source's default
limit: 300, // per page, capped by the source
));
$page->total; // how many the query matches, when the source says
foreach ($page as $occurrence) {
$occurrence->time; // when
$occurrence->place; // where: a Point
}
$next = $source->occurrences($query->with(['cursor' => $page->next]));
The query: Source\OccurrenceQuery
| Field | |
|---|---|
place |
a BBox, or a Point and its radius (km; null: the source's default, its README says which); null: anywhere |
from, to |
the period, both ends included, a date without a time standing for its whole day; an end left out is open |
filters |
array<string, mixed> by key - what only a domain filters on (below); a source refuses a key it does not read, before any call |
radius |
kilometres around a Point: a radius around a box, or without a place, is an InvalidArgumentException |
limit, cursor |
a page's size (each source caps it) and the Page::$next of the page before |
sort |
OccurrenceQuery::NEWEST, OLDEST, or a key the source sorts by (magnitude at the USGS); null: the source's own order. A source that cannot sort as asked refuses |
The filter keys several sources may read are constants of the query, so that a second source reuses a key before it makes one:
| Key | Value | Read by |
|---|---|---|
OccurrenceQuery::TAXON (taxon) |
an Identifier of the source's scheme, a key, or a scientific name |
gbif, inaturalist, obis, ebird (a species code) |
OccurrenceQuery::COUNTRY (country) |
ISO 3166-1 alpha-2 (ZA), or a list |
gbif |
OccurrenceQuery::KIND (kind) |
the source's word for what it is, or a list: earthquake, quarry blast (usgs); human_observation, preserved_specimen (gbif's basis of record) |
usgs, gbif |
OccurrenceQuery::MIN_MAGNITUDE, MAX_MAGNITUDE |
a magnitude, included | usgs, emsc |
OccurrenceQuery::LICENSE (license) |
the licence a record is shared under, as Creative Commons writes it lower case (cc0, cc-by, cc-by-nc...), or a list |
inaturalist |
A source may read keys of its own besides (the USGS reads alert, PAGER's level; the EMSC
magnitude_type; iNaturalist quality_grade; OBIS min_depth, max_depth, dataset, red_list;
eBird region, provisional); its README lists them.
A fossil is not an occurrence of this shape: its age is a span of geological time (72.2 to 66 Ma),
no instant. omnilab/pbdb gives the fossils of a place and an age through fossils(), outside
the contract: the same OccurrenceQuery for the place, filters and pages, a period refused, each
entity with its place and no time, its age in max_ma and min_ma.
The answer: a Page of Entity
The same Source\Page as search() and products(): items, next (null on the last page),
total when the service counts the matches. Every entity of it has a time and a place (a
Point): a source leaves out the records that lack either, or asks the service not to send them.
An entity of a page may hold less than entity() gives for it - the USGS's list has no event
products, so no uncertainties nor PAGER review -; entity($occurrence->id) reads it whole, and
each source's README says what a page leaves out.
What the occurrence is of goes into related, never into identifiers: a GBIF occurrence of a
lion carries the lion's taxon as related('taxon'). The Collector does not follow it and the
Merger joins nothing by it - two observations of one species are two occurrences, not one.
The capability
Capability::OCCURRENCES: the source answers occurrences(). Capability::AREA says it takes a
BBox as well as a Point. Without the capability, occurrences() throws NotSupportedException
(HttpSource does it for every source that does not override it).
| gbif | usgs | inaturalist | |
|---|---|---|---|
| Occurrences of | organisms: observations, specimens, samples | earthquakes and the other seismic events of ComCat | organisms: iNaturalist's observations, research grade by default |
| Place | a BBox (across the antimeridian too) or a Point and its radius (10 km by default) |
a BBox (across the antimeridian too) or a Point and its radius (100 km by default) |
a BBox (across the antimeridian too) or a Point and its radius (10 km by default) |
| Period | whole days | to the second | to the second (a bound at midnight: its whole day) |
| Filters | taxon, country, kind (basis of record) |
min_magnitude, max_magnitude, kind (event type), alert |
taxon, license, quality_grade |
| Sort | GBIF's own order only | newest (the default), oldest, magnitude, magnitude-asc |
none (the latest added first), newest, oldest (by the day observed) |
| Per page, at most | 300 | 20,000 | 200 |
| Deepest | 100,000 occurrences into a query (GBIF's search; its downloads, which need an account, go further) | none | none unsorted (paged by id); 10,000 sorted |
| Total | yes | no (a count would cost a call more) | on the first page (unsorted), on every page (sorted) |
In plain PHP
The whole of it, against the real services, no key:
require 'vendor/autoload.php';
use Omnilab\Gbif\GbifSourceFactory;
use Omnilab\Model\BBox;
use Omnilab\Model\Point;
use Omnilab\Source\OccurrenceQuery;
use Omnilab\Usgs\UsgsSourceFactory;
use Symfony\Component\HttpClient\HttpClient;
$http = HttpClient::create();
$gbif = (new GbifSourceFactory($http))->create(['mailto' => 'you@example.org']);
$usgs = (new UsgsSourceFactory($http))->create(['mailto' => 'you@example.org']);
// The lions observed within 100 km of Skukuza (Kruger National Park) in 2025.
$query = new OccurrenceQuery(new Point(-24.99, 31.59), new \DateTimeImmutable('2025-01-01'), new \DateTimeImmutable('2025-12-31'), [OccurrenceQuery::TAXON => '4CGXP'], radius: 100, limit: 300);
$lions = [];
do {
$page = $gbif->occurrences($query);
array_push($lions, ...$page->items);
$query = $query->with(['cursor' => $page->next]);
} while (null !== $page->next);
printf("%d lions (GBIF counts %d)\n", count($lions), $page->total);
// The earthquakes of magnitude 4 or more over Europe in the first week of October 2026, the largest first.
$europe = new BBox(-25, 35, 45, 72);
$page = $usgs->occurrences(new OccurrenceQuery($europe, new \DateTimeImmutable('2026-10-01'), new \DateTimeImmutable('2026-10-07'), [OccurrenceQuery::MIN_MAGNITUDE => 4], sort: 'magnitude', limit: 5));
foreach ($page as $quake) {
printf("%s %s %s\n", $quake->time->format('Y-m-d H:i'), $quake->place, $quake->label);
}
412 lions (GBIF counts 412)
2026-10-04 11:43 37.7292,36.1272 M 5.0 - 21 km ESE of Feke, Turkey
2026-10-04 11:19 37.8028,36.1749 M 4.7 - 21 km SSE of Saimbeyli, Turkey
2026-10-04 11:36 37.7757,36.1685 M 4.7 - 22 km E of Feke, Turkey
2026-10-05 17:28 64.5091,-17.3115 M 4.7 - 105 km WNW of Höfn, Iceland
2026-10-02 15:26 38.1524,21.8509 M 4.5 - 7 km NE of Chalandrítsa, Greece
(as answered on 2026-10-15: two pages of GBIF - 35 s the first time, its distance search slow, 3 s asked again -, one call to the USGS; the earthquakes' times are UTC)
Also answered that day: GBIF counts 513 lions in the box of the Kruger National Park (30.8, -25.6,
32.1, -22.3) by the name Panthera leo, 757 in South Africa in 2025, and 20,352 occurrences of the
lion in all, 16,675 of them present, dated and placed - what occurrences() asks for; the USGS,
eleven earthquakes of magnitude 4 or more over Europe that week, in three pages of four.
Not in the Collector
The Collector follows one thing into the sources that know it, by its identifiers. A page of
occurrences has no identity to follow, and two sources' pages hold different things (a lion is no
earthquake): there is nothing to join, and merging by nearness in place and time would guess -
two lions seen at one waterhole on one day are two occurrences. An occurrence of a page is an
entity like any: $collector->follow($occurrence) follows it (an earthquake of the USGS into
Wikidata by P3196; a GBIF occurrence published by iNaturalist into its observation, whose number it
carries: gbif.occurrence:5007681734 is inaturalist.observation:257284120, one call), and $collector->collect('gbif', $occurrence->related('taxon')->get(Scheme::GBIF))
reads what it is of.
Why this shape, its name and these choices: decisions.
Propose a change docs/occurrences.md
Datasets, requests, files
Three things the catalogues of data (the Copernicus data stores, NASA's CMR) do beside listing products, which two sources did by methods of their own and the contract now names:
| Asks | Answers | Capability | |
|---|---|---|---|
datasets(Query) |
a text | a Page of Product, each a dataset (a catalogue's entry: ERA5-Land, HLSS30 v2.0), the best match first; its id is what ProductQuery::$collections takes |
Capability::DATASETS |
submit($dataset, $request), job($id), jobs(ProductQuery) (JobSourceInterface) |
a request for a file of a dataset (its inputs, the dataset's own names), then the request by its id | a Job: its status and progress, once successful its file |
Capability::JOBS |
open(Asset|string) |
an asset (a granule's band, a job's file) or its address | the HTTP response, streamed, with the source's credentials sent | Capability::OPEN |
Nothing is decoded: a NetCDF, a GRIB, a GeoTIFF, a zip is the application's to read.
Datasets
use Omnilab\Cds\CdsSourceFactory;
use Omnilab\Earthdata\EarthdataSourceFactory;
use Omnilab\Source\Query;
use Symfony\Component\HttpClient\HttpClient;
$http = HttpClient::create();
foreach ([(new CdsSourceFactory($http))->create([]), (new EarthdataSourceFactory($http))->create([])] as $source) {
$page = $source->datasets(new Query('sea surface temperature', limit: 3));
printf("%s, %d datasets:\n", $source->getName(), $page->total);
foreach ($page as $dataset) {
printf(" %s %s %s..%s\n", $dataset->id, $dataset->properties['title'] ?? '', $dataset->start?->format('Y-m-d'), $dataset->end?->format('Y-m-d') ?? '');
}
}
cds, 68 datasets:
satellite-sea-surface-temperature Global sea surface temperature derived from satellite observations 1979-07-13..2026-09-23
satellite-ist-sst-global Combined sea surface and sea ice surface temperature data derived from satellite observations 1982-01-01..2026-09-12
satellite-sea-surface-temperature-ensemble-product Sea surface temperature daily gridded data from 1981 to 2016 derived from a multi-product satellite-based ensemble 1981-09-01..2016-12-31
earthdata, 4111 datasets:
OSTIA-UKMO-L4-GLOB-REP-v2.0_2.0 GHRSST Level 4 OSTIA Global Historical Reprocessed Foundation Sea Surface Temperature Analysis produced by the UK Meteorological Office 1982-01-01..2024-01-01
EO:EUM:DAT:SENTINEL-3:SL_2_WST___NRT_2017-07-05 SLSTR Sea Surface Temperatures (SST) in NRT - Sentinel-3 2017-07-05..
EO:EUM:DAT:SENTINEL-3:SL_2_WST___NTC_2017-07-05 SLSTR Sea Surface Temperatures (SST) in NTC - Sentinel-3 2017-07-05..
(as answered on 2026-10-15; the dataset's id then goes into new ProductQuery([$dataset->id], ...))
| cds, cams | earthdata | copernicus | |
|---|---|---|---|
datasets() |
the store's catalogue search, by relevance, paged by number | the CMR's collection search (keyword), paged by CMR-Search-After |
no: its catalogue's free-text search ranks nothing (alphabetical), breaks on a space (land surface temperature: a database's syntax error), and matches no dataset for "temperature"; its 427 collections whole weigh 3.5 MB and took over a minute (2026-10-15) |
Asynchronous requests: Job
Some data are made on request: the Climate and Atmosphere Data Stores cut ERA5 or a CAMS forecast
to the variables, days and area asked, queue the request, then keep the file for a while. A
source that takes such requests implements Source\JobSourceInterface:
$job = $cds->submit('reanalysis-era5-single-levels', ['product_type' => ['reanalysis'], 'variable' => ['2m_temperature'], 'year' => ['2025'], 'month' => ['07'], 'day' => ['01'], 'time' => ['12:00'], 'data_format' => 'netcdf', 'area' => [49, 7, 48, 8]]);
while (!$job->isDone()) {
sleep(10); // the source never waits: the application asks again
$job = $cds->job($job->id);
}
if ($job->isSuccessful()) {
$response = $cds->open($job->file()); // streamed: $response->getContent(), or HttpClient's stream()
}
(not run against the store: a request needs an account's key, and the dataset's licence accepted;
without one the store answers 401 Authentication failed: operation not allowed, as
sources shows)
A Job is a Product in the making - the dataset as its collection, created, started and
finished as its datetime, start and end, the file once made as its data asset - with:
status |
Job::ACCEPTED (queued), RUNNING, SUCCESSFUL, FAILED, REJECTED, DISMISSED (OGC API - Processes' words, the stores'), as the service says |
progress |
percent, when the service says (100 once successful); null otherwise |
message |
why it failed, when the service says |
isDone(), isSuccessful(), file() |
ended (successful, failed, rejected, dismissed); successful; the file's Asset (null before) |
jobs(ProductQuery) lists the account's requests, a page at a time, newest first (oldest with
ProductQuery::OLDEST); a store that lists them all together refuses a query that names datasets,
a place or a period.
Why a Job extends Product, and why a separate interface: decisions.
Propose a change docs/datasets.md
Following cross-references, merging
The Collector
$collector = new Collector($registry, new Merger());
$caffeine = $collector->collect('pubchem', 'caffeine'); // one source, then the others
$caffeine = $collector->collect('pubchem', 'caffeine', depth: 2); // and what those gave, in turn
$caffeine = $collector->follow($entity, 1, ['chebi', 'wikidata']); // an entity at hand, into the sources named
$found = $collector->search(new Query('caffeine')); // every source that searches, merged
$collector->incomplete; // the sources skipped, and why
collect() asks the first source for the entity, then offers each other configured source with
Capability::ENTITY the entity's identifiers, one after the other, until one names a thing the
source knows. A source that cannot read a scheme says so before any call
(NotSupportedException): only the identifiers of the schemes it reads cost a call. A source
found nothing for is not asked again; a source that read none of the identifiers may be offered
the next round's. With depth: 2, the identifiers the second round brought (a ChEBI entry's
Wikidata item, say) are offered to the sources not asked yet.
PubChem → ChEBI → Wikidata is the path the family was drawn for. With the three configured, caffeine from PubChem (as answered on 2026-10-15):
$caffeine = $collector->collect('pubchem', 'caffeine');
$caffeine->sources; // ['pubchem', 'chebi', 'wikidata']
$caffeine->properties('molecular_weight'); // 194.19 g/mol [pubchem], 194.194 g/mol [chebi]
$caffeine->properties('monoisotopic_mass');// 194.08037557 Da [pubchem], 194.08038 Da [chebi]
$caffeine->properties('is_a'); // purine alkaloid [chebi], trimethylxanthine [chebi]
$caffeine->properties('melting_point'); // 234 °C [wikidata], 235 °C [wikidata]
$caffeine->property('density'); // 1.23 g/cm³ [wikidata]
$caffeine->identifier(Scheme::KEGG); // C07481 (PubChem's synonyms); ChEBI and Wikidata add D00528
Nine calls: four of PubChem (the CID, the properties, the synonyms, the description); ChEBI is
offered the CID first, refuses it without a call, then reads the InChIKey (a search, then the
entry); Wikidata reads the CID (haswbstatement:P662=2519, then the item, then the label of its
class). ChEBI 2.0 gives no PubChem CID nor Wikidata item for caffeine: the InChIKey and the CID,
which PubChem gave, are what joins them. About eight seconds, PubChem's throttle and Wikidata's
answer (260 kB) for most of it.
Two paths more, through biology and taxonomy (answered on 2026-10-15). A protein, from UniProt into NCBI Gene and Wikidata - hemoglobin beta, chosen for an entry that does not move (reviewed, annotation score 5, its sequence unchanged since 2007):
$hbb = $collector->collect('uniprot', 'P68871', 1, ['ncbi', 'wikidata']);
$hbb->sources; // ['uniprot', 'ncbi', 'wikidata']
$hbb->identifiers()->keys(); // uniprot:P68871, ncbi.gene:3043, ensembl:ENSG00000244734, wikidata:Q424422
$hbb->properties('gene'); // HBB [uniprot], HBB [ncbi]
$hbb->property('mass'); // 15998 Da [uniprot]
$hbb->property('map_location'); // 11p15.4 [ncbi]
$hbb->property('genomic_location'); // 11:5225464-5227071 (minus strand), NC_000011.10, GRCh38.p14 [ncbi]
$hbb->related('organism')->keys(); // ncbi.taxon:9606: related, not followed
$hbb->related('ligand')->keys(); // chebi:58248, chebi:60344 (2,3-bisphosphoglycerate, heme b)
Five calls, about five seconds: UniProt's entry (its fields only, 59 kB); NCBI is offered the
accession, refuses it without a call, then reads GeneID 3043 (one Datasets report); Wikidata reads
the accession (haswbstatement:P352=P68871: Q424422, the protein's item - the gene has its own,
Q14905542), then the item (435 kB), then the label of its class. The organism, the structures and
the ligands are not offered to anyone: they are other things than the protein, kept in related.
A species, from GBIF into NCBI Taxonomy and Wikidata - Panthera leo:
$lion = $collector->collect('gbif', 'Panthera leo', 1, ['ncbi', 'wikidata']);
$lion->sources; // ['gbif', 'ncbi', 'wikidata']
$lion->identifiers()->keys(); // gbif:4CGXP, gbif:5219404, ncbi.taxon:9689, wikidata:Q140, inaturalist:41964
$lion->properties('taxon_name'); // Panthera leo [gbif], Panthera leo [ncbi], Panthera leo [wikidata]
$lion->properties('kingdom'); // Animalia [gbif], Metazoa [ncbi]: each base's own
$lion->property('iucn_status'); // Vulnerable (VU) [gbif]
$lion->properties('common_name'); // African Lion [gbif], lion [ncbi]
$lion->related('parent')->keys(); // gbif:6DBT, ncbi.taxon:9688: Panthera, in each base
Nine calls, about five seconds: three of GBIF (its match, the checklist's record, the ENA taxonomy's
usage that gives the NCBI id); NCBI refuses both GBIF keys without a call and reads 9689 (its report,
then its links, which give the legacy GBIF number back); Wikidata is offered GBIF's new key first
(haswbstatement:P14607=4CGXP: no item carries it yet), then the legacy number (P846=5219404:
Q140), then the item and the label of its class. The iNaturalist id comes from Wikidata's item.
An earthquake, from Wikidata into the USGS catalogue - the 2025 Myanmar earthquake, Wikidata's
Q133574091, whose P3196 is the USGS id us7000pn9s (answered on 2026-10-15). Wikidata gives the
day (P585) and its own epicentre; the USGS the second and its epicentre: preferring the USGS for
time and place takes those.
$collector = new Collector($registry, new Merger(['time' => ['usgs'], 'place' => ['usgs']]));
$quake = $collector->collect('wikidata', 'Q133574091', 1, ['usgs']);
$quake->sources; // ['wikidata', 'usgs']
$quake->identifiers()->keys(); // wikidata:Q133574091, usgs.event:us7000pn9s, usgs.event:usauto7000pn9s, usgs.event:pt25087002, usgs.event:at00sttln4
$quake->time; // 2025-03-28 06:20:52 UTC [usgs] (Wikidata: 2025-03-28)
$quake->place; // 22.011, 95.9363 [usgs] (Wikidata: 22.013, 95.922)
$quake->property('moment_magnitude'); // 7.7 [wikidata]
$quake->property('magnitude'); // 7.7 ± 0.028 [usgs], magnitude_type mww
$quake->property('depth'); // 10 ± 1.719 km [usgs]
$quake->value('alert'); // red (PAGER) [usgs]
$quake->value('felt'); // 2545 "Did You Feel It?" reports [usgs]; mmi 9.953, cdi 9.1
Three calls, about two seconds: Wikidata's item and the label of its class (earthquake), then the
USGS's event (query?format=geojson&eventid=us7000pn9s), whose other networks' ids join the
identifiers. Starting from the USGS (collect('usgs', 'us7000pn9s', 1, ['wikidata'])) costs four:
the event, then Wikidata's haswbstatement:P3196=us7000pn9s, the item and its class. The great
historical earthquakes carry in P3196 the id of a catalogue the USGS keeps rather than its own:
the 2011 Tohoku earthquake (Q36204) has official20110311054624120_30, which the USGS answers
with its M 9.1 (mww), depth 29 km and 05:46:24 UTC, joined in three calls as well - Wikidata adding
its Richter (9) and moment (9.1) magnitudes.
A gene, from NCBI Gene or UniProt into Ensembl, and back - human HBB (answered on 2026-10-15):
$hbb = $collector->collect('ncbi', '3043', 1, ['ensembl']); // or collect('uniprot', 'P68871', 1, ['ensembl'])
$hbb->sources; // ['ncbi', 'ensembl']
$hbb->identifiers()->keys(); // ncbi.gene:3043, uniprot:P68871, ensembl:ENSG00000244734
$hbb->properties('genomic_location'); // 11:5225464-5227071 (minus strand), NC_000011.10, GRCh38.p14 [ncbi],
// 11:5284832-5286439 (minus strand), NC_060935.1, T2T-CHM13v2.0 [ncbi],
// 11:5225464-5229395 (minus strand), GRCh38 [ensembl]
$hbb->properties('biotype'); // protein_coding [ensembl]
$gene = $collector->collect('ensembl', 'ENSG00000244734', 1, ['ncbi', 'uniprot', 'wikidata']);
$gene->sources; // ['ensembl', 'ncbi', 'uniprot', 'wikidata']
$gene->identifier(Scheme::WIKIDATA); // Q14905542: the HBB gene's item, by the Ensembl id (P594)
NCBI Gene and UniProt keep the gene's Ensembl id among their cross-references; Ensembl is offered it and reads the gene in four calls (its record, its species, its cross-references, its Swiss-Prot entries). Back, Ensembl gives NCBI Gene its GeneID (3043) and UniProt its Swiss-Prot entry (P68871); Wikidata finds the gene's own item by P594 (the protein has another, Q424422, which the path from UniProt reaches by P352). Ensembl's transcripts and proteins are other things: related.
A structure, taken alone - the PDB entries of hemoglobin beta. A structure is not its protein: UniProt keeps a protein's structures as related things, and a PDB entry has no identifier another source shares, so nothing joins them:
$hbb = $registry->get('uniprot')->entity('P68871');
$hbb->related('structure')->keys(); // pdb:1A00, pdb:1A01, pdb:1A0U... (20 kept by omnilab/uniprot)
$entry = $collector->collect('pdb', '4HHB');
$entry->sources; // ['pdb']: one call, nothing to follow
$entry->related('protein')->keys(); // uniprot:P69905, uniprot:P68871: its chains
$entry->related('ligand')->keys(); // inchikey:KABFMIBPWCXCRK-RGGAHWMASA-L (heme), chebi:18367 (phosphate)
$collector->collect('uniprot', $entry->related('protein')->get(Scheme::UNIPROT)); // back to a chain's protein
A species, from GBIF or Wikidata into iNaturalist, and back - Panthera leo again. iNaturalist gives no other base's id; Wikidata's item carries the iNaturalist taxon (P3151 = 41964):
$lion = $collector->collect('gbif', '4CGXP', 2, ['ncbi', 'wikidata', 'inaturalist']);
$lion->sources; // ['gbif', 'ncbi', 'wikidata', 'inaturalist']
$lion->identifiers()->keys(); // gbif:4CGXP, gbif:5219404, ncbi.taxon:9689, wikidata:Q140, inaturalist:41964
$lion->properties('iucn_status'); // Vulnerable (VU) [gbif], Vulnerable (VU) [inaturalist]
$lion->properties('common_name'); // African Lion [gbif], lion [ncbi], Lion [inaturalist]
Nine calls, about six seconds: the first round as above (GBIF, NCBI, Wikidata), the second offers
what Wikidata found to iNaturalist, which reads 41964 in one call. From Wikidata at depth 1
(collect('wikidata', 'Q140', 1, ['inaturalist'])) it takes three calls; from iNaturalist at depth 2
(collect('inaturalist', '41964', 2, ['wikidata', 'gbif', 'ncbi'])) nine: Wikidata finds the item by
P3151, then GBIF and NCBI read the ids it carries.
An occurrence, followed - GBIF's occurrences published by iNaturalist name the observation they
are (their occurrenceID is its page), which omnilab/gbif reads as an identifier:
$occurrence = $registry->get('gbif')->entity('gbif.occurrence:5007681734');
$occurrence->identifiers->keys(); // inaturalist.observation:257284120
$both = $collector->follow($occurrence, 1, ['inaturalist']);
$both->sources; // ['gbif', 'inaturalist']: one call more
$both->value('quality_grade'); // research [inaturalist], beside GBIF's dataset, licence and recorder
A page of occurrences is not followed (see occurrences); one occurrence is an entity like any.
A fly's gene, from Ensembl into NCBI Gene - Ensembl serves the fly under FlyBase's ids (the
ensembl.genomes scheme), and gives the NCBI Gene record among a gene's cross-references:
collect('ensembl', 'FBgn0000008', 1, ['ncbi']) - the fly's a, GeneID 43852, arc from each,
21.5 s on the evening of 2026-10-15 (Ensembl slow that day).
A planet, from Wikidata into SIMBAD and the NASA Exoplanet Archive - Wikidata's item for 51 Peg
b (Q242309) carries its SIMBAD identifier (P3083, * 51 Peg b) and its name at the archive (P5667,
51 Peg b, the exoplanet scheme):
$planet = $collector->collect('wikidata', 'Q242309', 1, ['simbad', 'exoplanets']);
$planet->sources; // ['wikidata', 'simbad', 'exoplanets']
$planet->identifiers()->keys(); // wikidata:Q242309, simbad:* 51 Peg b, exoplanet:51 Peg b, simbad:51 Peg b
$planet->properties('orbital_period'); // 4.2307969 ± 0.00000755 d [wikidata], 4.2307966 ± 0.0000027 days [exoplanets]
$planet->properties('parallax'); // 64.4048 ± 0.0771 mas [simbad], 64.6488 ± 0.123201 mas [exoplanets]
$planet->related('host'); // 51 Peg by its designations, gaia:2835207319109249920 among them
(3.9 s on the evening of 2026-10-15)
A star, from SIMBAD into Gaia DR3 - SIMBAD gives a star's Gaia DR3 designation among its
identifiers (simbad:Gaia DR3 4472832130942575872 for Barnard's star), which omnilab/gaia reads; the
Gaia entity carries it beside its own gaia:4472832130942575872, so the two merge:
collect('simbad', "Barnard's star", 1, ['gaia']) took two calls in 8.7 s on 2026-10-15
(SIMBAD's query, Gaia's for that source_id; the parallax 546.98 mas of each). Wikidata reaches Gaia
directly by P13228 (collect('wikidata', 'Q14268', 1, ['gaia']): the item carries
gaia:4472832130942575872). That evening the ESA Gaia Archive answered its maintenance page (an
UnavailableException): the Collector named gaia in $incomplete and gave SIMBAD's answer alone.
A pathway, from Wikidata into Reactome - Wikidata's item for glycolysis (Q45317172) carries its
Reactome ID (P3937, R-HSA-70171):
$glycolysis = $collector->collect('wikidata', 'Q45317172', 1, ['reactome']);
$glycolysis->sources; // ['wikidata', 'reactome']
$glycolysis->value('go_biological_process'); // canonical glycolysis (GO:0061621) [reactome]
$glycolysis->related('protein'); // 79 UniProt accessions, GCK (P35557) among them
(4 calls in 2.0 s on 2026-10-15: Wikidata's item and its classes' labels, Reactome's event and its
participants.) A protein's pathways are not a Collector path - a protein is not its pathways -
but omnilab/reactome's pathways() gives them: hemoglobin beta (UniProt P68871) takes part in ten
pathways and 28 reactions in Reactome's release 97.
A source that is down or rate limited is skipped and named in $incomplete; the others still
answer. Do not cache an answer while $incomplete is not empty.
A reaction, from KEGG into Rhea, and back - KEGG's reaction R00200 (pyruvate kinase) carries
rhea:18160, the bidirectional id of Rhea's reaction 18157, and Rhea's 18157 carries
kegg.reaction:R00200 and its three other ids: the Merger knows it by any of them.
$collector->collect('rhea', '18157', 1, ['kegg']); // Rhea, then KEGG by R00200
$collector->collect('kegg', 'R00200', 1, ['rhea']); // KEGG, then Rhea by 18160 (its master 18157)
Not walked live: KEGG's API is for academic users only, and omnilab/kegg is not tested live against it. Rhea's half was answered on 2026-10-15 (one SPARQL query, one UniProt search).
A protein, from UniProt into STRING, InterPro and QuickGO - walked against the real services on 2026-10-15. A protein's UniProt accession takes it into the sources that read accessions - STRING, InterPro, QuickGO - each adding what it knows of that protein: its interaction partners, its domains, its GO terms, all as related things:
$collector->collect('uniprot', 'P68871', 1, ['string']); // 3 calls, 3.1 s: ten partners, HBA2 first (0.999)
$collector->collect('uniprot', 'P68871', 1, ['interpro']); // 3 calls, 3.3 to 4.1 s: domain IPR000971 and PF00042, two families
$collector->collect('uniprot', 'P68871', 1, ['quickgo']); // 2 calls, 5.1 s: 8 functions, 11 processes, 9 components
InterPro gives a protein's length and organism under UniProt's keys (sequence_length,
organism), so that the merge sets both side by side (147 aa [uniprot], 147 aa [interpro]).
A gene, from HGNC into NCBI Gene, UniProt and Open Targets, and back - HGNC names it and records its other ids; the Collector walks from HGNC into NCBI Gene
and UniProt, from NCBI Gene back into HGNC, and from HGNC into Open Targets by the Ensembl gene:
$collector->collect('hgnc', 'HBB', 1, ['ncbi', 'uniprot']); // 6.8 s: 11p15.4 [hgnc], 11p15.4 [ncbi]
$collector->collect('ncbi', '3043', 1, ['hgnc']); // hgnc:4827 among the identifiers
$collector->collect('hgnc', 'HBB', 1, ['opentargets']); // 2 calls, 0.7 s: 552 associated diseases, 4 drugs
A disease, named in several ontologies - Open Targets names sickle cell disease MONDO_0011382 and gives its former ids
(EFO_0000697, Orphanet_232) and its OMIM and Orphanet ids as identifiers; ClinVar's conditions,
the GWAS Catalog's traits and STRING's enrichment name the same terms (Mondo, EFO, HPO), as related
things.
A variant, from ClinVar into the GWAS Catalog - ClinVar gives the sickle cell variant's rs id, which the GWAS Catalog reads:
$collector->collect('clinvar', '15333', 1, ['gwas']); // 3 calls, 1.7 s
sources = clinvar, gwas: ClinVar's classification (Pathogenic) and the catalogue's
associations (rs334-T, p = 1e-323 for hereditary hemolytic anemias) side by side. An rs id names
a site (rs334 holds four alleles), a ClinVar variation one of them: the two are joined as ClinVar
names them, not proved the same allele.
A structure from any of its names, into PubChem - CIR (omnilab/cactus) reads a name, a CAS number, a SMILES or an InChI and gives the standard InChIKey, which PubChem, ChEBI and ChEMBL read:
$caffeine = $collector->collect('cactus', 'caffeine', 1, ['pubchem']);
$caffeine->sources; // ['cactus', 'pubchem']
$caffeine->identifier(Scheme::PUBCHEM_CID); // 2519
$caffeine->properties('molecular_weight'); // 194.1926 g/mol [cactus], 194.19 g/mol [pubchem]
Walked on 2026-10-15 (.samples/live.php): CIR's eight calls, then PubChem by the InChIKey.
The Catalogue of Life, GBIF, iNaturalist and ITIS: the lion (walked on 2026-10-16) -
collect('col', '4CGXP', 1, ['gbif', 'inaturalist', 'itis']): the Catalogue of Life gives its
GBIF key (the same id: GBIF's taxonomy is the COL eXtended Release since June 2026), iNaturalist
41964 (an id COL names) and ITIS 183803 (the record COL took the lion from); four calls, 5.4 s;
sources = col, gbif, inaturalist, itis; family Felidae from the four, taxonomic_status
accepted [col], accepted [gbif], valid [itis], iucn_status Vulnerable (VU) [gbif] and
[inaturalist]. The other way, collect('gbif', '4CGXP', 1, ['col']): omnilab/col reads the GBIF
key as its own id (a legacy number, gbif:5219404, is refused: it is the Backbone's); two
sources, 2.4 s.
The sea: OBIS, WoRMS, ITIS and NCBI, the common dolphin (walked on 2026-10-16) - OBIS and
WoRMS key a taxon by the same AphiaID (worms:137094): the Merger takes them for one.
collect('obis', '137094', 1, ['worms', 'ncbi']): OBIS's 121,709 records and NCBI 9728, then
WoRMS's ITIS 180438 and IUCN 134817215 learnt on the way; 7.6 s. collect('worms', '137094', 1, ['itis', 'ncbi']): WoRMS's ITIS and NCBI ids followed; three common names, one a source (common
dolphin [worms], saddle-backed dolphin [itis], saddleback dolphin [ncbi]); 7.4 s (WoRMS's seven
calls weigh most). collect('col', 'Delphinus delphis', 1, ['worms', 'itis']): COL's dolphin
(34JWM) into WoRMS 137094 and ITIS 180438; 8.8 s. WoRMS also reads an ITIS, NCBI or IUCN id, so
an entity carrying one reaches WoRMS. With omnilab/iucn and a token, WoRMS's IUCN id leads to
the Red List's assessment (not walked: no token).
PBDB and eBird, from Wikidata - the Paleobiology Database and eBird give no other base's id;
Wikidata holds theirs (P10907, P3444), which omnilab/wikidata reads since 2026-10-16: a taxon's
item leads into both (below). Nothing leads out of them. PBDB's fossils (fossils()) are outside
the contract.
No path: the earth, water and sky sources of 2026-10-15/16 - omnilab/emsc, usgs-water, tides,
brightsky, swpc, elevation and openaq give no id of another base (Bright Sky's stations carry
their DWD and WIGOS ids, both its own), and Wikidata has no property for the EMSC's events: the
Collector has nothing to follow out of them. Into USGS Water Data, Wikidata's P13020 (a NWIS
site, read by omnilab/wikidata since 2026-10-16) leads from the four items that carry it on that
day (rain gauges in Mississippi): not walked. The EMSC's and the USGS's records of
one earthquake (the M 5.0 of central Turkey on 2026-10-04 at 11:43:23 UTC: emsc.event:20261004_0000156,
mb; usgs.event:us6000tzip, mww) stay two entities: joining them by time and place would be a
guess, which the Merger does not make.
From Wikidata into the sources of 2026-10-15/16
omnilab/wikidata reads, since 2026-10-16, the ids Wikidata keeps of the sources added those two
days (HGNC P354, OMIM P492, Mondo P5270, Orphanet P1550, HPO P3841, EFO P11956, ClinVar P1929,
dbSNP P6861, GO P686, InterPro P2926, Pfam P3519, EC P591, WoRMS P850, ITIS P815, the Catalogue of
Life P10585, PBDB P10907, eBird P3444, the IUCN P627, the MPC P5736, a USGS water site P13020,
CODATA P1645): an item leads into them. Walked against the real services on 2026-10-16
(.samples/live.php, section 60):
$gene = $collector->collect('wikidata', 'Q14905542', 1, ['hgnc']); // the HBB gene's item
$gene->sources; // ['wikidata', 'hgnc']: 3 calls, 3.1 s
$gene->identifiers()->keys(); // wikidata:Q14905542, ncbi.gene:3043, ensembl:ENSG00000244734, hgnc:4827, omim:141900, uniprot:P68871
$gene->property('map_location'); // 11p15.4 [hgnc]
$collector->collect('hgnc', 'HBB', 1, ['wikidata']); // and back: HGNC:4827 finds the gene's item (3 calls, 2.1 s)
$apophis = $collector->collect('wikidata', 'Q118931', 1, ['mpc']); // 99942 Apophis
$apophis->sources; // ['wikidata', 'mpc']: 5 calls, 3.9 s
$apophis->identifiers()->keys(); // wikidata:Q118931, jpl.sbdb:20099942, mpc:99942, jpl.sbdb:99942, mpc:2004 MN4
$apophis->properties('orbital_period'); // 323.5664375491 ± 0.0000012706 d [wikidata], 323.5553376252 d [mpc]
$apophis->property('semi_major_axis'); // 0.9223592225 ± 0.0000000003 au [mpc]
$robin = $collector->collect('wikidata', 'Q460967', 1, ['ebird', 'itis', 'worms', 'col']); // the American Robin
$robin->sources; // ['wikidata', 'ebird', 'itis', 'worms', 'col']: 13 calls, 7.3 to 15.5 s (two runs)
$robin->properties('family'); // Turdidae [ebird], [itis], [worms], [col]
$robin->properties('taxonomic_status'); // valid [itis], accepted [worms], accepted [col]
$robin->value('banding_codes'); // AMRO [ebird]
$proton = $collector->collect('wikidata', 'Q97275155', 1, ['codata']); // the proton mass
$proton->sources; // ['wikidata', 'codata']: 4 calls, 1.7 s
$proton->property('value'); // 1.67262192595e-27 ± 0.00000000052e-27 kg [codata]
Wikidata offers an HGNC id or a MIM number to the item of the gene, not of its protein (both carry
HGNC:4827: the protein's item is left out of the lookup). On a gene's or a protein's item, a GO
term, an InterPro entry, a Pfam family or an EC number is a class it belongs to: related (go,
domain, enzyme), not followed - two kinases that share EC 2.7.10.1 are not one. A compound's
COD structures and Materials Project material are related too (structure): Wikidata does not
lead into the COD nor the Materials Project, $item->related('structure') gives their ids.
The sky: small bodies and galaxies
Walked against the real services on 2026-10-16. The Minor Planet Center and JPL name a small
body by the same designation; an entity of either carries jpl.sbdb with it, which both read:
$collector->collect('mpc', '433', 1, ['jpl']); // (433) Eros: mpc, jpl - two calls at the MPC, one at JPL, 2.6 s
$collector->collect('jpl', '99942', 1, ['mpc']); // 99942 Apophis: jpl, mpc - 1.5 s
Each orbit is kept with its source: Eros's semi-major axis 1.4582437174 au [mpc] and
1.4582437168 ± 0.0000000002 au [jpl], its absolute magnitude 10.395 [mpc] and 10.4 [jpl];
identifiers mpc:433, jpl.sbdb:433, mpc:A898 PA, jpl.sbdb:20000433.
SIMBAD into NED: NED resolves the names SIMBAD gives (omnilab/ned reads the simbad
scheme). collect('simbad', 'M87', 1, ['ned']): M 87* with ned:Messier 087, redshift 0.0042
[simbad] and 0.004282963 ± 0.0000166782 [ned], 4.1 s; from Wikidata at depth 2,
collect('wikidata', 'Q14041', 2, ['simbad', 'ned']) reaches both (3.0 s). NED gives no other
base's id: nothing leads out of it. omnilab/horizons (series), omnilab/vizier (datasets),
omnilab/donki and omnilab/launches carry no cross-references: the Collector neither enters
nor leaves them.
The Merger
$merger = new Merger(['default' => ['pubchem', 'chebi'], 'description' => ['chebi'], 'molecular_weight' => ['pubchem']]);
$entities = $merger->merge($pubchemPage, $chebiList); // lists or pages: one entity per thing
$one = $merger->combine($a, $b, $c); // several that describe one thing, no check
$merger->same($a, $b);
Two entities are the same thing when they share an identifier, unless one carries, in the scheme of the other's own id, a different value: two PubChem compounds with two CIDs stay two, whatever CAS number they share - a salt and its base often do.
Each field from its preferred source: label, kind, description, url, image, time
and place come from the first entity, in the order of the sources' preference for that field,
that has one; domain from the first that is not other; id and source from the first in the
default order. A source not listed comes after those listed, in the order the entities were
given.
Every property kept, with its source: a key given by two sources keeps both properties, the
one of the source preferred for that key (or the default order) first: property($key) gives
it, properties($key) both. A property without a source takes its entity's.
United: identifiers (each once, in the default order of the sources), synonyms (each once
whatever its case, the label left out), sources.
Propose a change docs/merging.md
Configuration reference
The registry's configuration
new Registry(iterable $factories, array $config), and the bundle's omnilab.sources
(Symfony), take the same shape: a source's name, then the
name of its factory and its options.
new Registry([new PubChemSourceFactory($http), new OpenMeteoSourceFactory($http)], [
'pubchem' => ['factory' => 'pubchem', 'options' => ['mailto' => 'you@example.org']],
'meteo' => ['factory' => 'open-meteo'],
'air' => ['factory' => 'open-meteo', 'options' => ['api' => 'air_quality']],
]);
| Key | Type | Required | |
|---|---|---|---|
| the source's name | string |
yes | what get(), the Collector's $sources, the Merger's preferences and Entity::$source... call it; the configured order is the order names() and all() give, and the Collector asks |
factory |
string |
yes | a factory's getName(): pubchem, open-meteo, materials-project... (below), or an application's own |
options |
array<string, mixed> |
no ([]) |
handed to the factory's create() |
Registry method |
|
|---|---|
get(string $name): SourceInterface |
the source, built from its factory on the first call and kept; InvalidConfigException for a name not configured (No "x" source; configured: pubchem, meteo.), a factory not installed (No "x" factory for the "y" source; installed: ...), or an option it requires left empty |
has(string $name): bool |
configured or not (nothing is built) |
names(): list<string> |
the configured sources, in the configured order |
all(): array<string, SourceInterface> |
every configured source, built |
factories(): list<string> |
the factories given: what factory may name |
options(string $name): array |
the options a source is configured with, its factory's defaults not included |
How a factory reads its options
Every omnilab/* factory extends Source\SourceFactory. create($options) makes a Config of
the options given, lets the factory set its defaults under them (populate()), then the four
options every source takes, checks the options the factory requires, and builds the source
(build()):
- an option given as
nullcounts as not given: its default applies (Config::defaults()), so'api_key' => getenv('X') ?: nullis safe; - an option the factory requires (
omnilab.required_options) must be neither missing,null,''nor[]:InvalidConfigException(The "x" source needs: key.); no factory of the 56 requires one today: the sources that need a key refuse the calls that need it instead - iucn, and ebird beyond its taxonomy, with anInvalidConfigExceptionbefore any call, materials-project with aProviderExceptionbefore any call, while openaq sends the call and the service's 401 is aProviderException(Errors); - an option the factory does not know is kept in the
Configand ignored; $factory->createConfig($options)gives theConfigwithout building the source: the defaults the factory would apply, for a test or a diagnostic (->getArrayCopy()).
The factory takes the HTTP client to call with (new PubChemSourceFactory($http)); with none
it makes its own (HttpClient::create()).
The options every source takes
| Option | Type | Default | |
|---|---|---|---|
base_uri |
string |
the service's address (each source's, below) | another instance (a self-hosted Open-Meteo, a mirror, a development server: lldev.thespacedevs.com for Launch Library 2) |
mailto |
?string |
null | a contact address, sent in the User-Agent so that a service can write to the caller before it blocks them; NCBI's and ClinVar's E-utilities also send it as email |
user_agent |
?string |
null | the whole User-Agent, in place of the one made from mailto |
throttle |
float (ncbi, clinvar: 'auto') |
each source's, below (SourceFactory sets 0.0 when the factory sets none) |
seconds left between two calls of one source instance (HttpSource sleeps the rest); 0: none |
The User-Agent sent when user_agent is null is
omnilab/1.x (+https://github.com/glitchr-omnisense/omnilab; mailto:you@example.org), without
the mailto part when there is none. Wikimedia refuses (403) a request whose User-Agent does not
say who calls: the default one names the project; mailto adds whom to reach.
The throttle counts per source instance and per process: two instances of one source (two names, two workers) each keep their own interval. A service's limit is across all of them: the application keeps to it (Throttling and politeness gives each service's).
Each source's own options
The defaults each factory applies, read from createConfig([]) of every factory on 2026-10-27
(the mailto and user_agent defaults are null for all). What each option does, its type and
the per-call options a source reads are on the source's own page.
| Factory | Its own options (default) | throttle |
base_uri |
|---|---|---|---|
brightsky |
max_distance (null) |
0 | https://api.brightsky.dev/ |
cactus |
representations ([stdinchi, smiles, iupac_name, formula, mw, cas, names]), names (20), opsin (true) |
0 | https://cactus.nci.nih.gov/chemical/structure/ |
cams |
key (null) |
0 | https://ads.atmosphere.copernicus.eu/api/ |
cds |
key (null) |
0 | https://cds.climate.copernicus.eu/api/ |
chebi |
synonyms (20) |
0 | https://www.ebi.ac.uk/chebi/backend/api/public/ |
chembl |
synonyms (20), unichem (true), mechanisms (true), unichem_uri (https://www.ebi.ac.uk/unichem/api/v1/compounds) |
0 | https://www.ebi.ac.uk/chembl/api/data/ |
clinvar |
api_key (null), tool (omnilab), retries (1) |
auto | https://eutils.ncbi.nlm.nih.gov/entrez/eutils/ |
cod |
- | 0.5 | https://www.crystallography.net/cod/ |
codata |
- | 0 | https://physics.nist.gov/ |
col |
dataset (3LXR), language (eng) |
0 | https://api.checklistbank.org/ |
copernicus |
collections ([sentinel-2-l2a]) |
0 | https://stac.dataspace.copernicus.eu/v1/ |
donki |
- | 0 | https://ccmc.gsfc.nasa.gov/DONKI-API/get/ |
earthdata |
collections ([]), token (null), stac (null) |
0 | https://cmr.earthdata.nasa.gov/search/ |
ebird |
key (null), locale (en), radius_km (25) |
0 | https://api.ebird.org/v2/ |
elevation |
dataset (srtm30m), interpolation (bilinear) |
1 | https://api.opentopodata.org/v1/ |
emsc |
radius_km (100), min_magnitude (null) |
0 | https://www.seismicportal.eu/fdsnws/event/1/ |
ensembl |
species (homo_sapiens), xrefs (true) |
0.07 | https://rest.ensembl.org/ |
exoplanets |
- | 0 | https://exoplanetarchive.ipac.caltech.edu/ |
gaia |
- | 0 | https://gea.esac.esa.int/tap-server/tap/ |
gbif |
ncbi (true), language (eng), radius_km (10), checklist (7ddf754f-d193-4cc9-b351-99906754a03b) |
0 | https://api.gbif.org/ |
gwas |
associations (10) |
0 | https://www.ebi.ac.uk/gwas/rest/api/v2/ |
hgnc |
- | 0.1 | https://rest.genenames.org/ |
horizons |
- | 0.2 | https://ssd.jpl.nasa.gov/api/ |
inaturalist |
locale (en), quality_grade (research), radius_km (10) |
1 | https://api.inaturalist.org/ |
interpro |
- | 0 | https://www.ebi.ac.uk/interpro/api/ |
itis |
hierarchy (true), language (English) |
0 | https://www.itis.gov/ITISWebService/jsonservice/ |
iucn |
key (null), assessment (true), language (eng) |
0.5 | https://api.iucnredlist.org/api/v4/ |
jpl |
synonyms (20), now (null) |
0.2 | https://ssd-api.jpl.nasa.gov/ |
kegg |
databases ([compound]) |
0.34 | https://rest.kegg.jp/ |
launches |
api_key (null) |
0 | https://ll.thespacedevs.com/2.3.0/ |
materials-project |
api_key (null) |
0 | https://api.materialsproject.org/ |
mpc |
- | 0 | https://data.minorplanetcenter.net/api/ |
ncbi |
api_key (null), tool (omnilab), links (true), synonyms (20), eutils_uri (https://eutils.ncbi.nlm.nih.gov/entrez/eutils/) |
auto | https://api.ncbi.nlm.nih.gov/datasets/v2/ |
ned |
- | 0 | https://ned.ipac.caltech.edu/ |
noaa |
api (ncei), radius_km (50), timezone (null), now (null) |
0 | null: by api, NCEI's or the NWS's |
obis |
statistics (true), radius_km (10) |
0 | https://api.obis.org/v3/ |
open-meteo |
api (weather), api_key (null), timezone (auto), models (null), archive_delay (5), today (null), archive_uri (https://archive-api.open-meteo.com/v1/) |
0 | https://api.open-meteo.com/v1/ |
openaq |
api_key (null), radius_km (10) |
1 | https://api.openaq.org/v3/ |
opentargets |
associations (10) |
0 | https://api.platform.opentargets.org/api/v4/ |
oqmd |
- | 0 | https://oqmd.org/oqmdapi/ |
pbdb |
radius_km (10) |
0 | https://paleobiodb.org/data1.2/ |
pdb |
search_uri (https://search.rcsb.org/rcsbsearch/v2/query) |
0.25 | https://data.rcsb.org/ |
pubchem |
synonyms (20), description (true) |
0.2 | https://pubchem.ncbi.nlm.nih.gov/rest/pug/ |
quickgo |
annotation_pages (5) |
0 | https://www.ebi.ac.uk/QuickGO/services/ |
reactome |
species (Homo sapiens), participants (true) |
0 | https://reactome.org/ContentService/ |
rhea |
enzymes (25), sparql_uri (https://sparql.rhea-db.org/sparql), uniprot_uri (https://rest.uniprot.org/) |
0 | https://www.rhea-db.org/ |
simbad |
synonyms (50) |
0.2 | https://simbad.cds.unistra.fr/simbad/sim-tap/ |
string |
caller_identity (omnilab), species (9606), partners (10), required_score (400) |
1 | https://string-db.org/api/json/ |
swpc |
- | 0 | https://services.swpc.noaa.gov/ |
tides |
datum (MLLW), radius_km (50), application (omnilab), metadata_uri (https://api.tidesandcurrents.noaa.gov/mdapi/prod/webapi/) |
0.5 | https://api.tidesandcurrents.noaa.gov/api/prod/datagetter |
uniprot |
sequence (2000), structures (20) |
0 | https://rest.uniprot.org/uniprotkb/ |
usgs |
radius_km (100), min_magnitude (null) |
0 | https://earthquake.usgs.gov/fdsnws/event/1/ |
usgs-water |
api_key (null), radius_km (25) |
0 | https://api.waterdata.usgs.gov/ogcapi/v1/ |
vizier |
- | 0 | https://tapvizier.cds.unistra.fr/TAPVizieR/tap/ |
wikidata |
language (en), synonyms (20) |
0.3 | https://www.wikidata.org/w/api.php |
worms |
classification (true), synonyms (true), vernaculars (true), external_ids ([tsn, ncbi, iucn]), language (eng), marine_only (false) |
0 | https://www.marinespecies.org/rest/ |
The bundle
Symfony: omnilab.sources is the registry's configuration
above; omnilab.merger.preferences the Merger's.
The Merger's preferences
new Merger(array $preferences = []): for a field (label, kind, description, url,
image, time, place) or a property key (molecular_weight), the source names in the order
they are trusted; default orders every field and key not listed. A source not listed comes after
those listed, in the order the entities were given. Every property is kept whatever the
preferences: they decide which comes first (property()), not which survive (properties()).
new Merger(['default' => ['pubchem', 'chebi'], 'description' => ['chebi'], 'molecular_weight' => ['pubchem']]);
See the Merger.
The harness's environment
The harness configures every source from docker/.env (docker/harness/config/sources.php):
OMNILAB_MAILTO for every mailto; WIKIDATA_LANGUAGE (wikidata's language),
INATURALIST_LOCALE (inaturalist's locale); NCBI_API_KEY (ncbi's and clinvar's
api_key), OPEN_METEO_API_KEY and OPEN_METEO_BASE_URI, CDS_KEY (cds's key), ADS_KEY
(cams's key), EARTHDATA_TOKEN, USGS_WATER_API_KEY, EBIRD_API_KEY (ebird's key),
LL2_API_KEY (launches' api_key); and three sources only when their key is set:
materials-project (MP_API_KEY), openaq (OPENAQ_API_KEY), iucn (IUCN_API_KEY). It
configures open-meteo twice (air-quality: api: air_quality) and noaa twice (nws: api: nws). docker/.env.dist lists every variable but MP_API_KEY and LL2_API_KEY.
Propose a change docs/configuration.md
Errors
Unknown is not an error: entity() answers null, search(), occurrences(), products() and
datasets() an empty Page, series() a series without points (an empty list for a station it
does not know). Everything else a source
cannot or will not answer is an exception.
The exceptions
Every exception of Omnilab\Exception implements OmnilabException (an interface that extends
\Throwable):
| Exception | Extends | Thrown | Holds |
|---|---|---|---|
NotSupportedException |
\LogicException |
before any call: the source does not do that operation, cannot read that identifier's scheme, a filter key, a box, a station, a step, a sort | its message only (The "chebi" source cannot look up "pubchem.cid:2519"., The "open-meteo" source does not list products.) |
InvalidConfigException |
\LogicException |
a source not configured, a factory not installed, a required option empty (Registry::get(), SourceFactory::create()); and at call time, a key a call needs (iucn, ebird) |
its message |
ProviderException |
\RuntimeException |
the service answered an error (a 4xx other than 404, 410 and 429), or an answer that is not what was asked (not JSON, not a JSON object) | source (string), status (?int, the HTTP status), the message prefixed [source] |
UnavailableException |
ProviderException |
the service is down or unreachable: a 5xx, a timeout, a network error, a web page answered where JSON was asked (a maintenance page) | as ProviderException; never "not found" |
RateLimitedException |
UnavailableException |
429, or a budget spent | retryAfter (?int, seconds, from Retry-After when numeric), status 429 |
NotSupportedException::identifier($source, $identifier, $what = 'look up') and
::operation($source, $operation) make its two messages; a source uses them so that the
Collector can offer it identifiers at no cost.
Where the core throws them
| Where | Throws | When |
|---|---|---|
Registry::get() |
InvalidConfigException |
No "x" source; configured: a, b.; No "x" factory for the "y" source; installed: ... |
SourceFactory::create() (Config::validateNotEmpty()) |
InvalidConfigException |
The "x" source needs: key. |
HttpSource::entity(), search(), series(), products(), occurrences(), datasets(), open() not overridden |
NotSupportedException |
The "x" source does not look up entities. (search, serve series, list products, list occurrences, search datasets, open files) |
HttpSource::station() |
NotSupportedException |
a series asked at a station of a scheme the source does not read |
HttpSource::filters() |
NotSupportedException |
an occurrence filter key the source does not read (... (it reads "taxon", "country")) |
every call of HttpSource |
RateLimitedException, UnavailableException, ProviderException |
404, 410 and 204 are null; 429 rate limited; 5xx and transport errors unavailable; any other 4xx a provider error with what the service said (how each service writes it) |
HttpSource::postForJson(), getForJson() |
\InvalidArgumentException |
a body that cannot be written as JSON |
SeriesQuery, ProductQuery, OccurrenceQuery constructors |
\InvalidArgumentException |
no variable, a place that is no station, a period that ends before it starts, a radius without a Point or not above 0 |
Point, BBox (constructors and parse()) |
\InvalidArgumentException |
coordinates out of range, a south above the north, a text that is not lat,lon / west,south,east,north |
Identifier::of() |
\InvalidArgumentException |
an unknown scheme, a value not valid for it (tryOf() answers null instead) |
Stac::parameters() |
\InvalidArgumentException |
a cloud dialect other than Stac::CQL2, Stac::QUERY |
Merger::combine() |
\InvalidArgumentException |
nothing to combine |
The \InvalidArgumentExceptions are the caller's mistakes, thrown before anything is asked of a
service; they do not implement OmnilabException.
A source's key stays out of what HttpSource throws: in the query (api_key=) or a header (a
bearer, CDS's PRIVATE-TOKEN), it is masked in the HTTP client's message (Http\Redaction::text()),
the previous exception is a copy of the client's (Redaction::copy(): the same class and code),
and no trace of the chain keeps its arguments, where fetch()'s query and headers stood
(zend.exception_ignore_args off).
What the sources add
Beyond HttpSource's mapping, a source throws:
NotSupportedExceptionfor what it reads and what it does not: a scheme it cannot read, a step or aggregation the service does not have, a box where it reads a point, a sort it cannot apply, a period it cannot serve (each source's page lists its own under "Errors");ProviderExceptionfor an error the service writes with a 200 (RCSB's GraphQL, JPL Horizons, NOAA CO-OPS), and for a call refused for want of a key (materials-project, withoutapi_key, before any call);InvalidConfigExceptionat call time when a key the call needs is missing: iucn for every call, ebird for every call but the taxonomy;RateLimitedExceptiononce a retry is spent (clinvar makes a 429'd call again once, after the wait NCBI asks).
series() gives one series per variable asked, in the order asked, at a Point: a variable the
source has no value for there - no station near (noaa, tides, usgs-water), no location that
measures it (openaq), a product the station does not have - is a series without points, at the
point asked, so [$a, $b] = $source->series(...) holds. A station named by its Identifier that
the source does not know (brightsky, noaa, tides, usgs-water) answers an empty list: there is no
place to give the series at.
omnilab/emsc's event whose id it cannot read is a ProviderException (The SeismicPortal answered an event without an id it reads), as every answer a source cannot read.
What to catch
use Omnilab\Exception\NotSupportedException;
use Omnilab\Exception\ProviderException;
use Omnilab\Exception\RateLimitedException;
use Omnilab\Exception\UnavailableException;
try {
$compound = $registry->get('pubchem')->entity($name); // null: PubChem does not know it
} catch (NotSupportedException $e) {
// the source cannot read that: a programming or routing matter, not a service's answer
} catch (RateLimitedException $e) {
// slow down; $e->retryAfter seconds when the service says
} catch (UnavailableException $e) {
// down or unreachable: try later, and never cache this as "nothing"
} catch (ProviderException $e) {
// the service refused the request: $e->status, $e->getMessage() says why
}
Order the catch blocks from the narrowest: RateLimitedException before
UnavailableException, both before ProviderException. The Collector catches
UnavailableException (and NotSupportedException) itself: it skips the source, names it in
$collector->incomplete with the message, and the others still answer
(merging). A ProviderException that is not unavailability reaches the caller.
Propose a change docs/errors.md
Testing
Omnilab has no conformance suite: the core ships no *ContractTests class for a source to
extend, and no fake gateway. A source package's tests are its own (recorded answers on a
MockHttpClient, below); an application tests its code with the same two tools: a
MockHttpClient under a real source, or a source of its own that answers fixed entities.
The core's Tests\StubSource and Tests\StubSourceFactory are test classes of this package,
autoloaded in development only (autoload-dev): an application that installs
omnisense/omnilab does not get them.
A real source on recorded answers
Every factory takes the HTTP client to call with. Give it a MockHttpClient that answers each
path with a file recorded from the service: the source maps them exactly as it maps the live
answers, errors included (404 is null, 503 an UnavailableException).
<?php
use Omnilab\Exception\UnavailableException;
use Omnilab\Model\Scheme;
use Omnilab\PubChem\PubChemSourceFactory;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
// Recorded answers, by the path the source asks (PubChem's PUG REST).
$fixtures = __DIR__.'/Fixtures/';
$http = new MockHttpClient(static function (string $method, string $url) use ($fixtures): MockResponse {
$path = (string) parse_url($url, PHP_URL_PATH);
parse_str((string) parse_url($url, PHP_URL_QUERY), $query);
return match (true) {
str_ends_with($path, '/compound/name/cids/JSON') && 'caffeine' === ($query['name'] ?? null) => new MockResponse(file_get_contents($fixtures.'name-caffeine.json')),
str_contains($path, '/compound/cid/2519/property/') => new MockResponse(file_get_contents($fixtures.'properties-2519.json')),
str_ends_with($path, '/compound/cid/2519/synonyms/JSON') => new MockResponse(file_get_contents($fixtures.'synonyms-2519.json')),
str_ends_with($path, '/compound/cid/2519/description/JSON') => new MockResponse(file_get_contents($fixtures.'description-2519.json')),
default => new MockResponse('', ['http_code' => 404]),
};
});
$pubchem = (new PubChemSourceFactory($http))->create(['throttle' => 0]);
$caffeine = $pubchem->entity('caffeine');
echo $caffeine->label, ' ', $caffeine->id, ' ', $caffeine->property('molecular_weight'), ' CAS ', $caffeine->identifier(Scheme::CAS), "\n";
var_dump($pubchem->entity('no-such-compound')); // a 404: null
$down = (new PubChemSourceFactory(new MockHttpClient(new MockResponse('', ['http_code' => 503]))))->create(['throttle' => 0]);
try {
$down->entity('caffeine');
} catch (UnavailableException $e) {
echo get_class($e), ': ', $e->getMessage(), ' (', $e->status, ")\n";
}
Caffeine pubchem.cid:2519 194.19 g/mol CAS 58-08-2
NULL
Omnilab\Exception\UnavailableException: [pubchem] the service answered 503 (503)
(run on 2026-10-27 in the harness image, with no network, on the fixtures of
omnilab/pubchem's Tests/Fixtures/). Give throttle: 0: a source that spaces its calls
(PubChem's 0.2 s) would otherwise sleep in a test. The paths and queries each source asks are on
its page ("Which API", "Calls") and in its own tests, which are the most complete list.
A source of the application's own
A test of code that takes a Registry or a Collector needs no HTTP at all: a source that
extends HttpSource and overrides what it answers, its factory, and a Registry built on them.
HttpSource already refuses every operation not overridden with NotSupportedException, and
identify() reads identifiers as every source does.
<?php
use Omnilab\Collector;
use Omnilab\Exception\NotSupportedException;
use Omnilab\Model\Domain;
use Omnilab\Model\Entity;
use Omnilab\Model\Identifier;
use Omnilab\Model\Identifiers;
use Omnilab\Model\Property;
use Omnilab\Model\Quantity;
use Omnilab\Model\Scheme;
use Omnilab\Registry;
use Omnilab\Source\Capability;
use Omnilab\Source\HttpSource;
use Omnilab\Source\SourceFactoryInterface;
use Omnilab\Source\SourceInterface;
use Symfony\Component\HttpClient\MockHttpClient;
/** A source that knows the entities it is given, by every identifier of the schemes it reads. */
final class FixedSource extends HttpSource
{
/** @param list<Entity> $entities @param list<Scheme> $schemes */
public function __construct(private readonly string $name, private readonly array $entities, private readonly array $schemes)
{
parent::__construct(new MockHttpClient(), 'https://fixed.invalid/'); // never called
}
public function getName(): string
{
return $this->name;
}
public function capabilities(): array
{
return [Capability::ENTITY, Capability::CROSS_REFERENCES];
}
public function entity(Identifier|string $id): ?Entity
{
$id = self::identify($id, ...$this->schemes) ?? throw NotSupportedException::identifier($this->name, (string) $id);
foreach ($this->entities as $entity) {
if ($entity->identifiers()->has($id)) {
return $entity;
}
}
return null;
}
}
final class FixedSourceFactory implements SourceFactoryInterface
{
public function getName(): string
{
return 'fixed';
}
public function create(array $options = []): SourceInterface
{
return new FixedSource($options['name'], $options['entities'] ?? [], $options['schemes'] ?? []);
}
}
$cid = Identifier::cid(2519);
$chebi = Identifier::chebi(27732);
$registry = new Registry([new FixedSourceFactory()], [
'pubchem' => ['factory' => 'fixed', 'options' => ['name' => 'pubchem', 'schemes' => [Scheme::PUBCHEM_CID], 'entities' => [
new Entity($cid, 'Caffeine', Domain::CHEMISTRY, properties: [new Property('molecular_weight', 'Molecular weight', new Quantity(194.19, 'g/mol'), 'pubchem')], identifiers: Identifiers::of($chebi), source: 'pubchem'),
]]],
'chebi' => ['factory' => 'fixed', 'options' => ['name' => 'chebi', 'schemes' => [Scheme::CHEBI], 'entities' => [
new Entity($chebi, 'caffeine', Domain::CHEMISTRY, properties: [new Property('molecular_weight', 'Molecular weight', new Quantity(194.194, 'g/mol'), 'chebi')], source: 'chebi'),
]]],
]);
$caffeine = (new Collector($registry))->collect('pubchem', '2519');
echo $caffeine->label, ' from ', implode(', ', $caffeine->sources), ': ', implode(', ', array_map(static fn (Property $p) => $p.' ['.$p->source.']', $caffeine->properties('molecular_weight'))), "\n";
Caffeine from pubchem, chebi: 194.19 g/mol [pubchem], 194.194 g/mol [chebi]
(run on 2026-10-27 in the harness image, with no network). The Collector offered the PubChem
entity's ChEBI id to the chebi source, which read it, and the Merger kept both weights, the
first source's first.
In a Symfony application
The bundle builds every factory on the application's http_client service
(Symfony), so Symfony's own mock replaces the network for the whole
test environment:
# config/packages/test/framework.yaml
framework:
http_client:
mock_response_factory: App\Tests\RecordedAnswers # a callable: (string $method, string $url, array $options) => ResponseInterface
The Registry service is public: static::getContainer()->get(Registry::class) in a
KernelTestCase gives the configured sources. An application's own factory (a
SourceFactoryInterface under tests/, registered as a service in the test environment) is
autoconfigured, so a fixed source can be configured in config/packages/test/omnilab.yaml by its
factory's name.
The packages' own tests
Run from the workspace that holds every checkout (~/Sites/omnilab: its phpunit.xml.dist has
one test suite per package), or in the harness (docker compose run --rm omnilab test):
docker run --rm --network none -v "$PWD":/w:ro -w /w --entrypoint "" omnisense/omnilab-harness \
php vendor/bin/phpunit --do-not-cache-result
OK (781 tests, 6195 assertions)
(2026-10-27, ~/Sites/omnilab mounted read-only, no network: every test reads recorded answers.)
The core's 245 tests:
| Test class | Tests | Checks |
|---|---|---|
IdentifierTest |
187 | 7 test methods, most on data providers: every scheme's normalisation, the shapes parse() reads and those it leaves to a scheme, the pages and identifiers.org links read, url(), display() |
HttpSourceErrorsTest |
25 | each service's error body read into the exception's message (one case per form Sources lists), a maintenance page answered for JSON is unavailable, a long text cut |
ModelsTest |
13 | Quantity and Property as text (the very small and very large keep their digits), places, a series, a product and its assets, a job, the queries' refusals, a series at a station, an occurrence query and the filters a source refuses, postForJson() (204 is nothing) and getForJson() |
RegistryTest |
6 | the sources by name, built once; a required option; the Collector one round at a time, into the sources named, null when the first source does not know it, a search across sources merged |
BareTest |
5 | in a process of its own: the registry built by hand, each shape and the Collector on recorded answers with no class of a framework loaded; the bundle does load the framework |
MergerTest |
5 | entities that share an identifier are one, each property with its source; two CIDs stay two whatever they share; another identifier of its own scheme is still itself; related things join nothing; combining one gives its source |
StacTest |
3 | a query as an item search (parameters()), a page of items (page(), item()), a collection as a dataset (collection()) |
Bridge\OmnilabBundleTest |
1 | a kernel booted with the bundle (Symfony) |
Each source package's count is on Verified, and not.
A source package's tests follow one pattern: a MockHttpClient whose callback matches the path
and query asked to a file of Tests/Fixtures/ (each recorded from the service on a stated day,
some cut down, the README of the fixtures or the test's docblock says how), the calls logged and
asserted, and the errors checked on built responses (MockResponse('', ['http_code' => 503])).
Propose a change docs/testing.md
The Docker harness
docker/ runs this package with every omnilab/* source installed - from GitHub (branch 1.x),
or from the checkouts beside this one when OMNILAB_PLUGINS=../.. is set in docker/.env - and
a console that asks the real services.
cd docker && cp .env.dist .env # OMNILAB_MAILTO, WIKIDATA_LANGUAGE, INATURALIST_LOCALE, an NCBI or Open-Meteo key if you have one
docker compose run --rm omnilab sources
Open-Meteo is configured twice: open-meteo (the weather) and air-quality (api: air_quality).
| Command | |
|---|---|
sources |
the sources installed and configured, and what each can do |
entity <source> <id> |
one entity, by an identifier or a name (JSON) |
search <source> <text> |
--domain, --limit |
datasets <source> <text> |
the datasets of a catalogue (cds, cams, earthdata); --limit |
collect <source> <id> |
one entity, its cross-references followed into the other sources, merged (JSON); --depth |
series <source> <place> <variable>... (a place lat,lon, a box, or a station: ghcn:USW00094728, nws.station:KNYC) |
--from, --to, --daily mean|max|min|sum, --step PT15M, --timezone |
products <source> |
--point or --bbox, --collection (repeatable), --from, --to, --cloud, --oldest, --max, --json |
occurrences <source> |
--point and --radius (km), or --bbox; --from, --to; --filter key=value (repeatable, a key twice makes a list: -f taxon=4CGXP -f country=ZA, -f min_magnitude=4); --sort; --max, --json |
bare |
plain PHP: the registry built by hand, each shape asked of its service, what PHP loaded |
test |
every package's tests |
--from and --to take whatever PHP's DateTimeImmutable reads: 2026-09-01, tomorrow,
--from=-10days (with the =, or the console takes the minus for an option).
docker compose run --rm omnilab entity pubchem caffeine
docker compose run --rm omnilab entity pubchem CHEBI:27732
docker compose run --rm omnilab search pubchem caffeine --limit 5
docker compose run --rm omnilab collect pubchem caffeine # then ChEBI and Wikidata, merged
docker compose run --rm omnilab entity chebi CHEBI:27732
docker compose run --rm omnilab entity wikidata Q60235
docker compose run --rm omnilab search wikidata caffeine --domain chemistry --limit 5
docker compose run --rm omnilab entity uniprot P68871
docker compose run --rm omnilab collect uniprot P68871 # then NCBI Gene and Wikidata, merged
docker compose run --rm omnilab entity ncbi txid9689
docker compose run --rm omnilab search ncbi "Panthera[subtree]" --domain taxonomy
docker compose run --rm omnilab entity gbif 4CGXP # or 5219404, "Panthera leo"
docker compose run --rm omnilab collect gbif "Panthera leo" # then NCBI Taxonomy and Wikidata, merged
docker compose run --rm omnilab entity ensembl HBB # or ENSG00000244734, mus_musculus/Hbb-bs
docker compose run --rm omnilab collect ensembl ENSG00000244734 # then NCBI Gene, UniProt and Wikidata (P594), merged
docker compose run --rm omnilab entity pdb 4HHB # its chains' proteins and its ligands related
docker compose run --rm omnilab search pdb hemoglobin --limit 5
docker compose run --rm omnilab entity reactome R-HSA-70171 # glycolysis, its UniProt proteins and ChEBI molecules related
docker compose run --rm omnilab collect wikidata Q45317172 # glycolysis, then Reactome by P3937
docker compose run --rm omnilab entity hgnc HBB # HGNC:4827, its NCBI Gene, Ensembl, UniProt and OMIM ids
docker compose run --rm omnilab collect hgnc HBB # then NCBI Gene, Ensembl, UniProt, Open Targets... merged
docker compose run --rm omnilab entity string uniprot:P68871 # hemoglobin beta in STRING, its partners related
docker compose run --rm omnilab entity interpro uniprot:P68871 # its domains and families
docker compose run --rm omnilab entity quickgo GO:0005829 # cytosol, its parents
docker compose run --rm omnilab entity opentargets MONDO_0011382 # sickle cell disease, its targets and drugs
docker compose run --rm omnilab entity clinvar VCV000015333 # the sickle cell variant
docker compose run --rm omnilab collect clinvar 15333 # then the GWAS Catalog by rs334
docker compose run --rm omnilab entity gwas rs334 # its associations, the smallest p-value first
docker compose run --rm omnilab entity inaturalist 41964 # or "Panthera leo"
docker compose run --rm omnilab entity col 4CGXP # the lion in the Catalogue of Life: its GBIF key, iNaturalist and ITIS ids
docker compose run --rm omnilab collect col 4CGXP # then GBIF (the same key), iNaturalist and ITIS, merged
docker compose run --rm omnilab entity worms 137094 # the common dolphin: ITIS, NCBI and IUCN ids
docker compose run --rm omnilab entity obis 137094 # its records in OBIS
docker compose run --rm omnilab entity itis 180438
docker compose run --rm omnilab entity pbdb "Tyrannosaurus rex"
docker compose run --rm omnilab entity ebird eurrob1 # the taxonomy needs no key
docker compose run --rm omnilab entity iucn 134817215 # needs IUCN_API_KEY
docker compose run --rm omnilab entity chembl CHEMBL113
docker compose run --rm omnilab entity simbad Betelgeuse
docker compose run --rm omnilab entity jpl 433
docker compose run --rm omnilab entity mpc 433 # Eros at the Minor Planet Center
docker compose run --rm omnilab collect mpc 433 # then JPL's SBDB (the same designation), two orbits merged
docker compose run --rm omnilab series horizons 48.5734,7.7521 499:elevation 499:magnitude --from 2026-10-16 --to 2026-10-16 # Mars from Strasbourg
docker compose run --rm omnilab entity ned M31 # Messier 031: type, redshift, NED's counts
docker compose run --rm omnilab collect simbad M87 # then NED by SIMBAD's name
docker compose run --rm omnilab datasets vizier hipparcos --limit 5 # VizieR's tables
docker compose run --rm omnilab entity donki 2024-05-10T15:00:00-GST-001 # the Kp 9 storm of May 2024
docker compose run --rm omnilab entity launches 18441371-8b2e-457c-afb5-1ec1b11ab630
docker compose run --rm omnilab occurrences launches --from 2026-10-01 --to 2026-10-14 --max 10 # 15 calls an hour without LL2_API_KEY
docker compose run --rm omnilab entity gaia "Gaia DR3 4472832130942575872" # Barnard's star
docker compose run --rm omnilab entity exoplanets "51 Peg b"
docker compose run --rm omnilab collect wikidata Q242309 # 51 Peg b, then SIMBAD and the archive (P3083, P5667)
docker compose run --rm omnilab entity usgs us7000pn9s
docker compose run --rm omnilab collect wikidata Q133574091 # the 2025 Myanmar earthquake, then the USGS by P3196
docker compose run --rm omnilab entity emsc 20261004_0000156 # the EMSC's M 5.0 of central Turkey
docker compose run --rm omnilab entity usgs-water USGS-01646500 # the Potomac at Little Falls, the series it keeps
docker compose run --rm omnilab search usgs-water "potomac river" --limit 5
docker compose run --rm omnilab entity tides coops:8518750 # The Battery, New York
docker compose run --rm omnilab entity brightsky dwd:01420 # Frankfurt/Main
docker compose run --rm omnilab entity rhea RHEA:18160 # pyruvate kinase's reaction: its master 18157, ChEBI participants, EC, UniProt enzymes
docker compose run --rm omnilab search rhea "pyruvate kinase" --limit 3
docker compose run --rm omnilab entity kegg C07481 # KEGG: for academic use by academic users only
docker compose run --rm omnilab entity cod 9008565 # silicon: its cell, space group, publication, CIF
docker compose run --rm omnilab search cod caffeine --limit 5
docker compose run --rm omnilab entity cactus caffeine # or 58-08-2, an InChIKey, a SMILES
docker compose run --rm omnilab collect cactus caffeine # then PubChem (and ChEBI) by the InChIKey
docker compose run --rm omnilab entity codata mp # the proton mass, CODATA 2022
docker compose run --rm omnilab search codata "proton mass"
docker compose run --rm omnilab series usgs 35.6812,139.7671 count magnitude --daily max --from 2026-10-01 --to 2026-10-07
docker compose run --rm omnilab series emsc -25,35,45,72 count magnitude --daily max --from 2026-10-01 --to 2026-10-07
docker compose run --rm omnilab series usgs-water USGS-01646500 discharge gage_height --from 2026-10-14 --to 2026-10-14
docker compose run --rm omnilab series tides coops:8518750 water_level high_low_predictions --from 2026-10-14 --to 2026-10-14
docker compose run --rm omnilab series brightsky wmo:10637 temperature precipitation --from 2026-10-14 --to 2026-10-14
docker compose run --rm omnilab series swpc 64.84,-147.72 aurora # OVATION's latest nowcast over Fairbanks
docker compose run --rm omnilab series openaq 48.5734,7.7521 pm25 no2 # needs OPENAQ_API_KEY
docker compose run --rm omnilab series noaa ghcn:USW00094728 TMAX PRCP --step P1D --from 2026-10-01 --to 2026-10-07 # a station's own records
docker compose run --rm omnilab entity noaa ghcn:FR000007190 # the station itself
docker compose run --rm omnilab series open-meteo 48.5734,7.7521 temperature_2m --from tomorrow --to tomorrow
docker compose run --rm omnilab series open-meteo 48.5734,7.7521 temperature_2m precipitation_sum --daily max --from 2026-09-01 --to 2026-09-07
docker compose run --rm omnilab series air-quality 48.5734,7.7521 pm2_5 nitrogen_dioxide european_aqi --from 2026-10-14 --to 2026-10-14
docker compose run --rm omnilab series air-quality 48.5734,7.7521 european_aqi --daily max --from 2026-10-08 --to 2026-10-14
docker compose run --rm omnilab occurrences gbif --point -24.99,31.59 --radius 100 --from 2025-01-01 --to 2025-12-31 -f taxon=4CGXP --max 20
docker compose run --rm omnilab occurrences inaturalist --point -24.99,31.59 --radius 100 --from 2025-01-01 --to 2025-12-31 -f taxon=41964 --max 20
docker compose run --rm omnilab occurrences obis --bbox -10,43,-1,48 --from 2023-01-01 --to 2023-12-31 -f taxon=137094 --max 20
docker compose run --rm omnilab occurrences usgs --bbox -25,35,45,72 --from 2026-10-01 --to 2026-10-07 -f min_magnitude=4 --sort magnitude
docker compose run --rm omnilab occurrences emsc --bbox -25,35,45,72 --from 2026-10-01 --to 2026-10-07 -f min_magnitude=4 --sort magnitude
docker compose run --rm omnilab datasets earthdata "sea surface temperature" --limit 5
docker compose run --rm omnilab products copernicus --point 48.5734,7.7521 --from=-10days --cloud 30
docker compose run --rm omnilab products copernicus --bbox 7.68,48.53,7.83,48.64 --collection sentinel-1-grd --from=-3days
Bare: no bundle, no container
The console above is a symfony/console application over a registry built by hand; bare is
less still - one PHP script, docker/harness/bin/bare, that requires the autoloader and nothing
else. It builds the Registry from the source packages installed, asks each what it can do, then
asks each shape of its real service - caffeine of PubChem, followed by the Collector into ChEBI
and Wikidata, tomorrow's hourly temperature in Strasbourg of Open-Meteo, the Sentinel-2 L2A scenes
over Strasbourg in the last ten days under 30 % cloud of the Copernicus Data Space, the largest
earthquakes (five at most) of magnitude 4 or more over Europe in the last seven days of the USGS - or,
with --recorded, reads the answers kept in
docker/harness/recorded/; then it lists what PHP loaded and exits 1 if a class of a framework is
among it (Symfony\Component\DependencyInjection, Config, HttpKernel, HttpFoundation, a
bundle, Doctrine, Twig):
$ docker compose run --rm omnilab bare
Omnilab in bare PHP: the registry built by hand, no bundle, no container.
pubchem entity search structure cross_references
open-meteo series forecast historical
air-quality series forecast historical
copernicus products area cloud_cover
chebi entity search structure cross_references
wikidata entity search cross_references
chembl entity search structure cross_references
uniprot entity search cross_references
ncbi entity search cross_references
gbif entity search occurrences area cross_references
simbad entity search
jpl entity search
usgs entity series occurrences historical area
ensembl entity search cross_references
pdb entity search structure
noaa series historical
nws series forecast historical
cds products area
cams products area
earthdata products area cloud_cover
inaturalist entity search occurrences area
obis entity occurrences area cross_references
worms entity search cross_references
itis entity search
col entity search cross_references
pbdb entity search
ebird entity search occurrences
PubChem, caffeine, asked of pubchem.ncbi.nlm.nih.gov:
pubchem.cid:2519 Caffeine
molecular_formula C8H10N4O2
molecular_weight 194.19 g/mol
xlogp -0.1
tpsa 58.4 Ų
inchikey RYYVLZVUVIJVGH-UHFFFAOYSA-N
identifiers inchikey:RYYVLZVUVIJVGH-UHFFFAOYSA-N inchi:InChI=1S/C8H10N4O2/c1-10-4-9-6-5(10)7(13)12(3)8(14)11(6)2/h4H,1-3H3 cas:58-08-2 dsstox:DTXSID0020232 chebi:27732 chembl:CHEMBL113 unii:3G6A5W338E kegg:C07481 wikidata:Q60235
The Collector, caffeine from PubChem followed into ChEBI and Wikidata, asked of each:
sources pubchem, chebi, wikidata
molecular_weight 194.19 g/mol [pubchem] 194.194 g/mol [chebi]
monoisotopic_mass 194.08037557 Da [pubchem] 194.08038 Da [chebi]
is_a purine alkaloid [chebi] trimethylxanthine [chebi]
melting_point 234 °C [wikidata] 235 °C [wikidata]
density 1.23 g/cm³ [wikidata]
Open-Meteo, the hourly temperature in Strasbourg on 2026-10-16, asked of api.open-meteo.com:
00:00 12.2 01:00 11 02:00 10.8 03:00 10.1 04:00 10 05:00 10.1 06:00 10 07:00 10.1 08:00 10.2 09:00 10.3 10:00 10.7 11:00 11.4 12:00 13 13:00 15.8 14:00 18.1 15:00 19.8 16:00 20.9 17:00 21.3 18:00 21.1 19:00 20 20:00 19 21:00 18.2 22:00 17.5 23:00 17.2 (°C)
Copernicus, the Sentinel-2 L2A scenes over Strasbourg in the last ten days under 30 % cloud, asked of stac.dataspace.copernicus.eu:
2026-10-12 10:29 UTC 28.94 % MGRS-32UMU S2A_MSIL2A_20261012T102911_N0513_R108_T32UMU_20261012T170607
2026-10-12 10:29 UTC 1.84 % MGRS-32ULU S2A_MSIL2A_20261012T102911_N0513_R108_T32ULU_20261012T170607
2026-10-05 10:28 UTC 11.10 % MGRS-32UMU S2B_MSIL2A_20261005T102859_N0513_R108_T32UMU_20261005T142235
2026-10-05 10:28 UTC 5.62 % MGRS-32ULU S2B_MSIL2A_20261005T102859_N0513_R108_T32ULU_20261005T142235
USGS, the largest earthquakes (five at most) of magnitude 4 or more over Europe in the last seven days, asked of earthquake.usgs.gov:
2026-10-09 04:46:52 UTC 35.1045,23.4684 M 4.6 - 23 km SW of Palaióchora, Greece
2026-10-10 22:33:28 UTC 35.4717,26.5886 M 4.2 - 31 km WNW of Fry, Greece
Loaded from Symfony: Symfony\Component\HttpClient, Symfony\Contracts\HttpClient, Symfony\Contracts\Service
Classes of a framework (DependencyInjection, Config, HttpKernel, HttpFoundation, a bundle, Doctrine, Twig): none
(as answered on 2026-10-15, in the afternoon: Open-Meteo's forecast for the 16th had moved since the morning's 10.4 to 20.4 °C)
bare --recorded --json prints the same whole, every class and file loaded, without a call:
Tests/BareTest.php runs it in a process of its own and checks the list.
The image is php:8.4-cli-alpine with Composer; the harness's packages live in the harness
volume of the omnilab-harness project.
Propose a change docs/harness.md
Writing a provider
A provider is a package omnilab/<slug> (repository github.com/glitchr-omnisense/omnilab-<slug>,
namespace Omnilab\<Name>\) holding a factory and a source. Its 56 predecessors follow one
layout; Sources states the rules in short, this page step by step.
The package
omnilab-<slug>/
<Name>Source.php the source: extends Omnilab\Source\HttpSource
<Name>SourceFactory.php its factory: extends Omnilab\Source\SourceFactory
Tests/<Name>SourceTest.php
Tests/Fixtures/ the service's answers, recorded on a stated day
docs/index.md its page of the manual
README.md what it reads, its terms of use, its Responsibility section
LICENSE MIT
composer.json
{
"name": "omnilab/<slug>",
"type": "library",
"license": "MIT",
"require": {
"php": ">=8.2",
"omnisense/omnilab": "^1.0@dev",
"symfony/http-client": "^6.4|^7.0|^8.0"
},
"require-dev": { "phpunit/phpunit": "^11.0" },
"autoload": { "psr-4": { "Omnilab\\<Name>\\": "" }, "exclude-from-classmap": ["/Tests/"] },
"autoload-dev": { "psr-4": { "Omnilab\\<Name>\\Tests\\": "Tests/" } }
}
A package that calls mb_* functions requires ext-mbstring or symfony/polyfill-mbstring, as
the thirteen source packages that call them now do (Requirements).
The factory
populate() names the factory, lists the options it requires and sets the defaults of the
others; build() makes the source from the filled Config. SourceFactory adds the defaults of
mailto, user_agent and throttle (0.0) after populate(), checks the required options, and
builds the User-Agent (self::userAgent($c)).
namespace Omnilab\Oqmd;
use Omnilab\Config;
use Omnilab\Source\SourceFactory;
use Omnilab\Source\SourceInterface;
use Symfony\Component\HttpClient\HttpClient;
final class OqmdSourceFactory extends SourceFactory
{
protected function populate(Config $c): void
{
$c->defaults([
'omnilab.factory_name' => 'oqmd',
'omnilab.required_options' => [],
'throttle' => 0.0,
'base_uri' => OqmdSource::BASE_URI,
]);
}
protected function build(Config $c): SourceInterface
{
return new OqmdSource(
$this->http ?? HttpClient::create(),
(string) $c->get('base_uri', OqmdSource::BASE_URI),
['User-Agent' => self::userAgent($c)],
(float) $c->get('throttle'),
);
}
}
- The name (
omnilab.factory_name) is whatfactory:names: the slug, lower case. - The throttle's default is the service's published rate (PubChem's five calls a second: 0.2), 0 when none is published.
- Each option the source reads has a default here, and a line of the factory's docblock that says what it does: the provider's page copies them into its options table.
- A key the service needs for every call may be a required option (
InvalidConfigExceptionatcreate()); the packages written so far keep it optional and refuse the call instead, so that a registry configured without the key still builds.
The source
final class OqmdSource extends HttpSource
{
public const BASE_URI = 'https://oqmd.org/oqmdapi/';
public function getName(): string
{
return 'oqmd';
}
public function capabilities(): array
{
return [Capability::ENTITY, Capability::SEARCH];
}
public function entity(Identifier|string $id): ?Entity
{
$identifier = self::identify($id, Scheme::OQMD) ?? throw NotSupportedException::identifier('oqmd', (string) $id);
$data = $this->getJson('formationenergy', ['filter' => 'entry_id='.$identifier->value, 'limit' => 1, 'format' => 'json'])['data'] ?? [];
// ... the row read into an Entity, or null
}
}
What HttpSource gives:
| Member | |
|---|---|
entity(), search(), series(), products(), occurrences(), datasets(), open() |
throw NotSupportedException until overridden: override only what the service answers, and list it in capabilities() |
getJson($path, $query, $headers) |
a GET read as JSON; null on 404, 410 and 204 |
getJsonWithHeaders(...) |
the same with the answer's headers by lower-case name, for a service that pages or counts in them |
postForJson($path, $body, $query, $headers) |
a JSON body POSTed (GraphQL, a search API, a batch lookup), JSON read |
getForJson(...) |
a JSON body on a GET (the Minor Planet Center's APIs) |
get($path, $query, $headers) |
a GET read as text (a CSV, a flat file, a VOTable) |
station(SeriesQuery $query, Scheme ...$schemes) |
the station a series query names, when of a scheme the source reads; null for a place; NotSupportedException for another station |
filters(OccurrenceQuery $query, string ...$known) |
the occurrence filters, when the source reads every key; NotSupportedException for another key |
identify(Identifier|string $id, Scheme ...$schemes) |
an identifier of one of the schemes, from an Identifier or a string (scheme:value, a page, a value its shape or the first scheme reads); null for anything else - a name, for a source that reads names |
text(?string) |
tags stripped, entities decoded, white space collapsed; null when empty |
time(?string) |
an ISO 8601 time; null when absent or unreadable |
$http, $baseUri, $headers, $throttle |
the constructor's, for a call HttpSource does not make (a form POST, a stream) |
A path starting with http is called as it is; any other is joined to the base URI. A query
value that is a list repeats its key; null and '' are left out; a boolean is true / false.
Every call waits the throttle, and its answer is mapped as Errors
says. A call a source makes by itself ($this->http->request()) maps its own errors the same way.
The rules a source keeps
- Refuse before any call. An identifier of a scheme it does not read, an operation, a filter,
a box, a station, a step it cannot serve:
NotSupportedException, before calling anything. TheCollectoroffers every identifier to every source and relies on it. - Unknown is null, an empty page, a series without points (one per variable asked, in the
order asked; an empty list only for a station the source does not know); never an exception. Down is an
UnavailableException, never null: a caller caches null. - Identifiers name the thing itself; other things it is tied to go in
related, under a relation that says what (organism,parent,target,taxon...), reusing a relation's name before making one (decisions). - Property keys are shared: reuse a key another source gives for the same meaning
(
molecular_weight,mass,taxon_rank,magnitude) before making one; lower case, snake case (models). - Units as the service gives them, in a
Quantity: no conversion. - An occurrence has a time and a place: leave out the records that lack either; what it is
of is
related, never an identifier (occurrences). - A series per variable asked, in the order asked; a variable it does not know is refused before the call, or answered by the service's error.
- The
Page::$nextcursor is the source's own: an offset, a token; null on the last page.
A new scheme
A base whose identifiers have no scheme yet adds one to the core first (Model\Scheme), in one
change of omnisense/omnilab:
- the case and its value (lower case, dots for a sub-kind:
ebird.checklist); - its
label()and itsdomain()(both arematchexpressions over every case: a case missing from either throws\UnhandledMatchError, asLAUNCH_LIBRARYdid fromdomain()until it was added (the methods of Scheme); - its normalisation in
Identifier::tryOf(), its page inurl(), its pages read back infromUrl(), its prefixes (identifiers.org's, the base's own) inIdentifier::PREFIXES, and its shape inparse()only when no other scheme shares it; - its cases in
Tests/IdentifierTest.php, and its row in models.
Registering it
| Where | What |
|---|---|
Bridge/Symfony/OmnilabBundle.php |
the factory in FACTORIES (and its use), its name in the sources node's info() |
composer.json (core) |
a suggest line |
docker/harness/plugins.php |
the slug, the tests' namespace, the factory class |
docker/harness/config/sources.php |
the source's options from the environment, and needs when it requires a key |
docker/.env.dist |
the variables it reads |
the workspace (~/Sites/omnilab/composer.json, phpunit.xml.dist) |
a path repository and a test suite |
README.md, index, Sources |
its row; what it does and does not; its throttle and contact |
Tests/Bridge/OmnilabBundleTest.php |
its factory's name among those registered |
Its tests
One test class, a MockHttpClient whose callback matches each path and query to a recorded
answer of Tests/Fixtures/ (the day of the recording in the docblock; a file cut down says so),
the calls logged and asserted (how many, in which order), and at least:
- each operation on a recorded answer, every field and property mapped;
- what is refused before any call (an identifier of another scheme, each refused query);
- null for an unknown thing, an empty page for no match;
- the errors: a 404, a 429 with
Retry-After, a 5xx, an error the service writes with a 200; capabilities(), and the options read (the User-Agent sent, a key in its header).
See Testing for the pattern, and any provider's Tests/ for a complete one.
Its page
docs/index.md, one file, h1 the package name, then: the service and the API(s) it calls;
Installation; Credentials; Options (every option, type, default, effect; the per-call options
such as occurrence filters); Capabilities (every capability, supported or not); the behaviour of
each call; an example in plain PHP and the Symfony configuration; Errors; Verified, and not;
Responsibility. The existing pages are the model.
Propose a change docs/providers.md
Verified, and not
What the tests prove
Every package's tests run on recorded answers, with no network: 781 tests, 6195 assertions, all
green on 2026-10-27 (the workspace ~/Sites/omnilab mounted read-only in the
omnisense/omnilab-harness image, --network none). The core's 245 prove the contract itself:
the identifiers of every scheme, the error mapping of HttpSource, the models and queries, the
Registry, the Collector and the Merger on stub sources, the STAC reader, plain PHP without a
framework, and the Symfony bundle in a booted kernel (Testing).
A source's tests prove that it reads the answers it was given: each fixture was recorded from the service on a stated day (some cut down), or, where no live answer could be had, built after the service's documentation or schema - those packages say so, and say which.
What ran against the services
| 2026-10-14 to 2026-10-16 | every source but those below, while it was written: the calls of its page, the answers its fixtures keep; each page's "Verified, and not" says what and when |
| 2026-10-15 | the Collector from PubChem into ChEBI and Wikidata, the installation's scripts (Installation), the error bodies of each service (Sources) |
| 2026-10-27 | the installation's plain PHP script again (PubChem, Open-Meteo, the Copernicus Data Space), in the harness image (its output) |
| 2026-10-27 | each provider page's example, against its service: 51 answered; OQMD was down (502, time-outs); materials-project and iucn refused before any call without a key, openaq answered 401 without one, ebird answered its taxonomy and refused the rest; KEGG's ran offline on its recorded answers (academic use only). Each page's "Verified, and not" gives what that run showed |
The harness keeps twelve answers recorded live (docker/harness/recorded/: ChEBI, the Copernicus
Data Space, Open-Meteo, PubChem, the USGS, Wikidata) that bare and the core's BareTest replay.
What did not
- Keys never used: OpenAQ (every call), the IUCN Red List (every call), the Materials Project
(every call), eBird (all but its taxonomy), the Copernicus data stores' requests (
submit(),job(),jobs()of cds and cams), NASA Earthdata's files (open()with a token). Their tests answer bodies built from the services' documentation, schemas or official clients. - Services not answering or not open to this use: OQMD (down on 2026-10-15: fixtures from its documentation), KEGG (academic use by academic users only: a handful of calls to learn its format, no use beyond).
- The Laravel provider of Laravel: plain construction, not run in a Laravel application.
- Rates: no source was run at the limit its service publishes; the throttles are the published rates, not measured ones.
By package
| Package | Tests | Assertions | Live |
|---|---|---|---|
omnilab/brightsky |
10 | 42 | live, on the days its page states (2026-10-14 to 2026-10-16) |
omnilab/cactus |
6 | 65 | live, on the days its page states (2026-10-14 to 2026-10-16) |
omnilab/cams |
4 | 31 | its catalogue live on 2026-10-15, without an account; submit(), job(), jobs() not live (a key) |
omnilab/cds |
11 | 90 | its catalogue live on 2026-10-15, without an account; submit(), job(), jobs() not live (a key) |
omnilab/chebi |
11 | 75 | live, on the days its page states (2026-10-14 to 2026-10-16) |
omnilab/chembl |
15 | 80 | live, on the days its page states (2026-10-14 to 2026-10-16) |
omnilab/clinvar |
9 | 120 | live, on the days its page states (2026-10-14 to 2026-10-16) |
omnilab/cod |
6 | 85 | live, on the days its page states (2026-10-14 to 2026-10-16) |
omnilab/codata |
5 | 756 | live, on the days its page states (2026-10-14 to 2026-10-16) |
omnilab/col |
8 | 32 | live, on the days its page states (2026-10-14 to 2026-10-16) |
omnilab/copernicus |
4 | 32 | live, on the days its page states (2026-10-14 to 2026-10-16) |
omnilab/donki |
7 | 71 | live, on the days its page states (2026-10-14 to 2026-10-16) |
omnilab/earthdata |
11 | 78 | its search live on 2026-10-15, without an account; open() with a token not live |
omnilab/ebird |
8 | 31 | its taxonomy live on 2026-10-16; everything that needs a key not tested live (constructed answers) |
omnilab/elevation |
7 | 24 | live, on the days its page states (2026-10-14 to 2026-10-16) |
omnilab/emsc |
13 | 62 | live, on the days its page states (2026-10-14 to 2026-10-16) |
omnilab/ensembl |
14 | 69 | live, on the days its page states (2026-10-14 to 2026-10-16) |
omnilab/exoplanets |
14 | 221 | live, on the days its page states (2026-10-14 to 2026-10-16) |
omnilab/gaia |
9 | 139 | live, on the days its page states (2026-10-14 to 2026-10-16) |
omnilab/gbif |
15 | 96 | live, on the days its page states (2026-10-14 to 2026-10-16) |
omnilab/gwas |
10 | 96 | live, on the days its page states (2026-10-14 to 2026-10-16) |
omnilab/hgnc |
8 | 85 | live, on the days its page states (2026-10-14 to 2026-10-16) |
omnilab/horizons |
11 | 102 | live, on the days its page states (2026-10-14 to 2026-10-16) |
omnilab/inaturalist |
13 | 66 | live, on the days its page states (2026-10-14 to 2026-10-16) |
omnilab/interpro |
9 | 106 | live, on the days its page states (2026-10-14 to 2026-10-16) |
omnilab/itis |
5 | 27 | live, on the days its page states (2026-10-14 to 2026-10-16) |
omnilab/iucn |
6 | 34 | not tested live: a token is required, none used (the 403 without one, met on 2026-10-16); tests on answers of 2024-09-18 |
omnilab/jpl |
11 | 80 | live, on the days its page states (2026-10-14 to 2026-10-16) |
omnilab/kegg |
10 | 124 | not tested live: academic use by academic users only; a handful of calls on 2026-10-15 to learn the format |
omnilab/launches |
7 | 70 | live, on the days its page states (2026-10-14 to 2026-10-16) |
omnilab/materials-project |
3 | 38 | not tested live: a key is required, none created (the 401 without one, met on 2026-10-15); fixtures built on its OpenAPI schema, numbers placeholders |
omnilab/mpc |
10 | 90 | live, on the days its page states (2026-10-14 to 2026-10-16) |
omnilab/ncbi |
10 | 63 | live, on the days its page states (2026-10-14 to 2026-10-16) |
omnilab/ned |
7 | 76 | live, on the days its page states (2026-10-14 to 2026-10-16) |
omnilab/noaa |
13 | 105 | live, on the days its page states (2026-10-14 to 2026-10-16) |
omnilab/obis |
10 | 262 | live, on the days its page states (2026-10-14 to 2026-10-16) |
omnilab/open-meteo |
9 | 67 | live, on the days its page states (2026-10-14 to 2026-10-16) |
omnilab/openaq |
7 | 25 | not tested live: a key is required, none created (the 401 without one, met on 2026-10-15) |
omnilab/opentargets |
9 | 136 | live, on the days its page states (2026-10-14 to 2026-10-16) |
omnilab/oqmd |
3 | 31 | not tested live: the service did not answer on 2026-10-15; fixtures from its documentation |
omnilab/pbdb |
9 | 358 | live, on the days its page states (2026-10-14 to 2026-10-16) |
omnilab/pdb |
10 | 49 | live, on the days its page states (2026-10-14 to 2026-10-16) |
omnilab/pubchem |
10 | 72 | live, on the days its page states (2026-10-14 to 2026-10-16) |
omnilab/quickgo |
11 | 116 | live, on the days its page states (2026-10-14 to 2026-10-16) |
omnilab/reactome |
12 | 120 | live, on the days its page states (2026-10-14 to 2026-10-16) |
omnilab/rhea |
5 | 57 | live, on the days its page states (2026-10-14 to 2026-10-16) |
omnilab/simbad |
10 | 103 | live, on the days its page states (2026-10-14 to 2026-10-16) |
omnilab/string |
10 | 109 | live, on the days its page states (2026-10-14 to 2026-10-16) |
omnilab/swpc |
9 | 42 | live, on the days its page states (2026-10-14 to 2026-10-16) |
omnilab/tides |
10 | 51 | live, on the days its page states (2026-10-14 to 2026-10-16) |
omnilab/uniprot |
12 | 69 | live, on the days its page states (2026-10-14 to 2026-10-16) |
omnilab/usgs |
17 | 111 | live, on the days its page states (2026-10-14 to 2026-10-16) |
omnilab/usgs-water |
12 | 59 | live, on the days its page states (2026-10-14 to 2026-10-16) |
omnilab/vizier |
6 | 58 | live, on the days its page states (2026-10-14 to 2026-10-16) |
omnilab/wikidata |
28 | 132 | live, on the days its page states (2026-10-14 to 2026-10-16) |
omnilab/worms |
7 | 41 | live, on the days its page states (2026-10-14 to 2026-10-16) |
The core: 245 tests, 766 assertions.
The defects found while writing this manual are fixed since, each with its test: the mb_*
requirement left undeclared (Installation), the bundle's
normalised source names (Symfony),
Scheme::LAUNCH_LIBRARY->domain() (Models), omnilab/emsc's
\UnexpectedValueException and the series left out or answered as an empty list
(Errors).
Propose a change docs/verified.md
Decisions
What the contract settled, and why. Each was taken with the family's author; a change to one is a change of the contract.
Identifiers name the thing; related things are another field (2026-10-15)
Entity::$identifiers holds the other names of the same thing in other bases: a compound's
CAS number and ChEBI id, a protein's NCBI gene record, a taxon's legacy GBIF number. The
Collector follows them into the other sources; the Merger takes two entities that share one
for one thing.
Entity::$related (array<string, Identifiers>, by relation) holds other things an entity is
tied to: a protein's organism (ncbi.taxon:9606), its structures (pdb:1A3N), the ligands
and cofactors it binds (chebi:60344), a molecule's targets, a taxon's parent, a synonym's
accepted taxon, an occurrence's taxon. The Collector does not follow them; the Merger
joins nothing by them, and unites them when it merges.
Why: UniProt gives a protein's organism - human hemoglobin beta (P68871), the NCBI taxon
9606. Kept among the protein's identifiers, it would have sent the Collector from the protein
into NCBI Taxonomy's Homo sapiens, and the Merger would have fused the two because they shared
an identifier: a protein and a species one entity, the species' lineage among the protein's
properties - and every human protein one with every other. An identifier says "is", a relation "is tied to"; one list could not hold both.
How to apply: a new source puts in identifiers only what names the very thing (when in
doubt, ask: would two entities that share it be one?), and the rest in related, under a relation
that says what it is (organism, parent, target...), reusing a relation's name before making
one.
Applied since: omnilab/pdb keeps an entry's chains' proteins (protein), their organisms and
its ligands as related things - a structure is not its protein, and two structures of hemoglobin
are not one -, so a PDB entry carries no identifier another source shares and the Collector takes
it alone. omnilab/ensembl relates a gene's transcripts and a transcript's protein (other things),
and keeps among a gene's identifiers its NCBI Gene record and its Swiss-Prot entries, as NCBI and
UniProt already joined a gene and its protein. A GBIF occurrence published by iNaturalist carries
the observation's number among its identifiers: it is that very record (inaturalist.observation).
Ensembl Genomes: a scheme beside ensembl (2026-10-15)
Scheme::ENSEMBL_GENOMES (ensembl.genomes) names the genes, transcripts and proteins Ensembl
serves under their community's ids - FlyBase's FBgn0000008, Araport's AT5G10140, SGD's
YAL001C -, which ensembl (ENS... only) leaves out.
Why a scheme of its own: ensembl is told by its shape (ENSG00000244734 is nothing else),
which parse() and every source rely on; widened to every community's id, it would read any code
as an Ensembl id. A scheme beside it keeps that, and is itself told by its shape only for the ten
genomes whose ids were checked one by one against rest.ensembl.org (a table, Identifier:: ENSEMBL_GENOMES); any other id of Ensembl Genomes goes with its scheme. Why a table with the
species: rest.ensembl.org serves these genomes (the old rest.ensemblgenomes.org no longer
answers: its certificate is another host's), but a lookup without the species took 7 to 23 s, or
answered 500; with it, a few seconds. Why not the communities' schemes (flybase, tair,
sgd): their own ids are not all Ensembl's (SGD's S000003909 is not), and what the sources share
is the id Ensembl gives.
Datasets, requests and files in the contract (2026-10-15)
SourceInterface::datasets(Query) (Capability::DATASETS) and open(Asset|string)
(Capability::OPEN); JobSourceInterface (submit(), job(), jobs(), Capability::JOBS)
and Model\Job (datasets).
Why: omnilab/cds and omnilab/cams searched their catalogue (datasets()) and took requests
(submit(), job(), jobs()), omnilab/earthdata searched its collections (datasets()) and read
a file with its token (open()), each outside the contract: an application could not ask "which
datasets speak of sea surface temperature" or "read this file" of a source without knowing which.
Why "datasets": what a scientist looks for, and what the stores call their entries (the CDS's
datasets, the CMR's "data set" landing pages, DataCite's and schema.org's Dataset); the
methods already had the name, so no call changes. Not collections: STAC's word, which
ProductQuery::$collections keeps for what a search of products is narrowed to - a dataset's id
is what goes there. Not search(): it answers entities, a thing named; a dataset is a catalogue's
entry, a Product without a time of its own, as products() of the CDS already answered.
Why a Job is a Product: what a request becomes is a product - a file of a dataset over a
place and a period, with an address -, and the Product cds answered for a job (its status in
properties, its file the data asset) is what callers already read: Job extends Product
(no longer final) and adds the status, the progress, the message; every read of before reads the
same.
Why a separate interface for jobs: cds's submit(), job(), jobs() answered a Product.
Put in SourceInterface and given defaults in HttpSource, they would have made an older cds
fatal against this core (a Product cannot answer for a Job); a source that takes requests
implements JobSourceInterface beside, and says Capability::JOBS. datasets() and open()
had the very signatures of the methods before them: in SourceInterface, with HttpSource's
refusals.
Why open() and no decoding: the files are NetCDF, GRIB, GeoTIFF, HDF, zip - each the
application's tools' to read; what the source owns is the credential (Earthdata Login's token,
the store's key) and where the file is. It answers the response streamed, not read.
Not copernicus's datasets(): its catalogue's free-text search ranks nothing, breaks on a
space and finds nothing for "temperature"; reading its 427 collections to rank them here took over
a minute (2026-10-15). Its collections stay named in its docs.
A station in place of a point (2026-10-15)
SeriesQuery::$place is a Point, a BBox or an Identifier: a station's (ghcn, wigos,
nws.station), Capability::STATION saying that a source reads them. A station is an Entity
too (kind station, its place and elevation, the period its records cover), which entity()
answers.
Why: a series at a point is the source's choice of where - Open-Meteo's grid point, NCEI's
nearest station that has the variables (around Strasbourg, Entzheim rather than Kehl, nearer but
without TMAX since 2003). A climatologist asks for a station's records by its id, and omnilab/noaa
did it by a method of its own, seriesAt(), outside the contract. A station is a place with an
identity: what an Identifier is for, not a field of its own.
Why these schemes: each base's own id, as everywhere in the contract - GHCN's (NCEI's stations
of the whole world, 133,039 on 2026-10-15), the WMO's WIGOS id (the register every national
service reports to, the one Wikidata keeps, P4136; the five-digit index of old is its
0-20000-0- form) and the NWS's (an ICAO airport, a buoy, a network's code: its own list).
GHCN's and the NWS's shapes are any code's - eleven or four characters -, so a bare value is not
read as one: a name such as "CAFFEINATED" would have been a station.
Why not a field station: a query that named a point and a station would say two places;
place says one, of three kinds, and a source refuses the kinds it does not read before any call,
as it refuses a box without Capability::AREA.
Applied: omnilab/noaa reads ghcn stations through NCEI and nws.station through the NWS,
and answers their records; seriesAt() stays, deprecated, through series(). Open-Meteo and the
USGS refuse a station.
A fourth shape: occurrences (2026-10-15)
SourceInterface::occurrences(OccurrenceQuery): Page<Entity>, Capability::OCCURRENCES - the
things found or happened at a place in a period (occurrences). Two sources
needed it and neither fitted: GBIF's occurrences (left out of omnilab/gbif at first) and the
USGS's earthquakes of a region, which omnilab/usgs gave by a method of its own, events(),
outside the contract.
Why not one of the three shapes:
- not
search(): itsQueryis a text; a place and a period are not a text, and a search is "which things are named so", not "which happened there"; - not a
Series: a series is one variable at one place over time; each occurrence has its own place, its own properties and its own identity (the USGS gives each event's count or largest magnitude per day as series, and keeps doing so - another question); - not a
Product: a product is a catalogued file (its footprint, its assets to download, the catalogue's raw properties); an occurrence is a thing, with an identifier theCollectorcan follow (an earthquake's into Wikidata), properties with their units and sources, and related things (its taxon) - what anEntityholds.
So the answer is the Entity already known, with its time and its place (the fields were
made for "what happens somewhere at a moment"), and only the question is new.
Why "occurrences": the word says "was found, or happened, at a place and a time" for either kind of thing - Darwin Core's occurrence, the record of the largest of these services (GBIF: 3,981,411,127 on 2026-10-15), and the seismologists' "earthquake occurrence". Events was the USGS's word, but in Darwin Core an Event is a sampling event, a class of its own beside the Occurrence, and in the applications that call omnilab an event is a dispatched object; observations leaves out the museum specimens and the instruments' detections; records and features say nothing.
Why these choices:
- The place is a
BBox, or aPointand a radius - the two ways the services take it (GBIF'sgeoDistanceand its ranges, the FDSN'smaxradiuskmand its box); the radius belongs to the query, the source's option is only its default. - What only a domain filters on is a filter by key, not a typed field: a taxon, a country, a
magnitude are each one domain's, and a typed field per domain would grow the core with every
source - the contract is shapes, not domains. The keys several sources share are constants of
the query (
TAXON,COUNTRY,KIND,MIN_MAGNITUDE,MAX_MAGNITUDE), and a source refuses a key it does not read before any call (HttpSource::filters()), as it refuses a box withoutCapability::AREA: never an answer silently unfiltered. - A
Page, assearch()andproducts(): the services page (GBIF by offset, 300 a page, 100,000 deep; the USGS by offset, 20,000 a page), andtotalsays the count when the service gives it (GBIF does, the USGS would need a call more). - Sort is a string:
NEWEST,OLDEST, or a key of the source (magnitude); null leaves the source's order, and a source that cannot sort as asked refuses (GBIF sorts nothing). - Every entity of a page has its time and place: a source asks for them or leaves out what
lacks them (GBIF:
hasCoordinate, a date range), so a caller never checks. - Not in the
Collector: a page has no identity to follow and two sources' pages hold different things; merging by nearness would guess. One occurrence is an entity like any, whichfollow()takes (occurrences). events()of omnilab/usgs stays, deprecated: it answers what it answered, throughoccurrences().
A quantity's text keeps its significant digits (2026-10-16)
(string) Quantity writes a float below 1e-4 or from 1e9 up in scientific notation, with the
fewest digits that give the number back (all it holds, none invented), and its uncertainty in the
same exponent: 1.67262192595e-27 ± 0.00000000052e-27 kg, 1.98841e30 ± 0.00004e30 kg. Between
the two, as before: ten decimals at most, the trailing zeros dropped (194.19 g/mol,
0.9223592225 ± 0.0000000003 au); an integer as it is (61000000000 kg). An uncertainty that ten
decimals would round to 0 while the value is ordinary is written in scientific notation of its own
(1.5 ± 3e-13).
Why: the text was %.10F, ten fixed decimals. omnilab/codata's proton mass, 1.67262192595e-27
kg with an uncertainty of 5.2e-37, read 0 ± 0 kg, and the Sun's mass the binary noise of a
31-digit integer: a reader saw nothing, or digits nobody measured. Why the same exponent: as
CODATA writes them (1.672 621 925 95 e-27, 0.000 000 000 52 e-27): the uncertainty is read
against the value's last digits, and each side stays a number a parser reads. Why 1e-4 and 1e9:
below 1e-4 ten decimals keep fewer than seven significant digits; from 1e9 a float's ten decimals
are mostly noise. Why nothing else moves: callers and tests compare these texts; every value
of ordinary magnitude, the numbers themselves (value, uncertainty, number()) and a source's
text as published (value_text) are unchanged.
The core is published as omnisense/omnilab (2026-10-17)
The family's core is the Composer package omnisense/omnilab; it was glitchr/omnilab. Its
composer.json declares "replace": {"glitchr/omnilab": "self.version"}: an application or a
provider that still requires glitchr/omnilab resolves it to this package, at the same version,
and never installs both. The providers keep their vendor (omnilab/pubchem...) and require
omnisense/omnilab; the namespace Omnilab\ and the GitHub repositories
(glitchr-omnisense/omnilab, glitchr-omnisense/omnilab-<provider>) do not change.
Why: omnisense/ is the vendor of every family's core (omnilab, omnibus, omnitrade,
omnistate...), the providers the vendor of their family (omnilab/), the Symfony bundles
omnibase/: the vendor says which layer a package is, and the cores sit together on Packagist,
apart from the studio's other packages. How to apply: require omnisense/omnilab; replace
glitchr/omnilab in an existing composer.json when convenient - nothing breaks meanwhile.