ObsCore¶
Deployments running the odp metadata plugin publish
ObsCore 1.1 — the IVOA's common
model for observational datasets — as the table ivoa.obscore, one row per
ingested data product. Any ObsTAP-aware client (pyvo's
TAPService.search, TOPCAT's ObsTAP window, Aladin) discovers it through
the declared data model:
<dataModel ivo-id="ivo://ivoa.net/std/ObsCore#core-1.1">ObsCore-1.1</dataModel>
Nothing is ingested twice: the SRCNet ODP model is itself ObsCore-derived,
so ivoa.obscore is a projection of the srcnet tables — materialised as
a table of its own, built by the plugin's bootstrap exactly when its source
tables are and kept current by triggers on them (see How the table stays
current). All 30 mandatory columns of REC
Table 6 are present with their standard units, UCDs and utypes.
The column mapping¶
| ObsCore column | comes from |
|---|---|
dataproduct_type |
data_products.dataproduct_type, with the srcnet-only value 'table' mapped to 'measurements' (the ObsCore vocabulary has no table) |
calib_level |
data_products.calib_level, passed through untranslated — see Calibration levels |
obs_collection |
observations.collection, 'unclassified' when absent (the REC requires a value) |
obs_id |
data_products.obs_id |
obs_publisher_did |
constructed — see below |
access_url, access_format, access_estsize |
one representative artifact per product: the first science-semantics artifact by id. access_estsize converts the model's bytes to the REC's kbyte. A product with no science artifact has NULL access columns, which the spec permits |
target_name, s_ra, s_dec, s_fov, s_region, s_xel1, s_xel2, t_min, t_max, t_exptime, t_xel, em_min, em_max, em_xel, o_ucd, pol_states, pol_xel |
the identically-named data_products columns |
s_resolution |
data_products.beam_size (already arcseconds) |
t_resolution, em_res_power |
NULL — the model does not carry them, and NULL is permitted |
facility_name, instrument_name |
the identically-named observations columns |
s_region_geom (non-standard, std = 0) |
the footprint derived from s_region at ingestion (a pgsphere spoly, or a skycell skyregion under TAP_GEOMETRY_BACKEND=skycell) — see Metadata plugins |
The extra s_region_geom column is what makes footprint queries work
directly on the table:
SELECT obs_publisher_did, dataproduct_type, access_url
FROM ivoa.obscore
WHERE 1 = INTERSECTS(s_region_geom, CIRCLE('ICRS', 150.0, -30.0, 0.5))
The publisher DID¶
obs_publisher_did is a configurable prefix plus the product's full
identity chain:
<prefix><project_id>/<obs_id>/<sbd_id>/<eb_id>/<product_id>
The key columns are free text in the data model, so each one is
percent-encoded into the path: everything outside the RFC 3986 unreserved
set (A-Za-z0-9._~-) becomes %XX per UTF-8 byte. A product_id of
cube 3/a therefore appears as cube%203%2Fa rather than forging a sixth
path segment, and two products can never collide on one identifier. The
/ separators between components are the only unencoded ones.
The encoder is the SQL function ivoa.did_encode(text), IMMUTABLE and
PARALLEL SAFE, and a component that is already clean never calls it. The
DID is a stored column of the table and its unique key (obscore_did_key;
the encoding makes it injective over the key chain), with a pg_trgm GIN
index beside it (obscore_did_trgm), so a lookup by DID — equality, a
prefix, or a component in the middle such as obs_publisher_did LIKE
'%<project_id>/%' — is an index scan. pg_trgm is a trusted extension, so
the database owner can install it without being superuser; a bootstrap role
that cannot logs a warning and leaves the table without the trigram index.
The function, the table's select, its triggers and its indexes are
fingerprinted together, so a changed prefix or chain rebuilds the table at
the next bootstrap.
Choosing the chain¶
Which columns form the chain is set with config.obscoreDidColumns (env
TAP_OBSCORE_DID_COLUMNS), dot-separated and in order:
config:
obscoreDidColumns: "project_id.obs_id.sbd_id.eb_id.product_id"
The default is the primary key of srcnet.data_products — one
component per level of the ODP hierarchy — because that is exactly what
identifies a data product uniquely. Set it only for a data model that nests
differently, and with the same care the prefix deserves:
- a chain that does not identify a product uniquely makes two products share a DID, which the percent-encoding cannot save you from;
- changing it re-identifies every dataset already published.
Each component must be a plain lower-case SQL identifier; the names are
interpolated into the table definition, where nothing can be bound, so
anything else is refused before it reaches a CREATE VIEW. A name that is
syntactically fine but absent from srcnet.data_products fails at
bootstrap, where PostgreSQL names it.
The prefix defaults to ivo://skao.int/~? and is set with
config.obscoreDidPrefix (env TAP_OBSCORE_DID_PREFIX). A PublisherDID is
a permanent promise: set the authority to match your registry
authorityId before publishing anything real, because changing the prefix
re-identifies every dataset ever served. The prefix is validated against
the IVOA identifier alphabet before it is interpolated into the table
definition.
Calibration levels¶
calib_level is the one column where SRCNet and ObsCore 1.1 do not agree,
and the table passes the value through untranslated:
| level | srcnet (model schemas) | ObsCore 1.1 |
|---|---|---|
| 0 | raw | raw — instrumental data in a proprietary format |
| 1 | calibrated | instrumental data in a standard format (FITS, VOTable) |
| 2 | science-ready | calibrated, science-ready, instrument signature removed |
| 3 | analysis | enhanced data products (mosaics, source catalogues, cubes) |
Levels 0 and 3 line up. Level 1 does not: srcnet calls it calibrated, where ObsCore reserves that word for level 2 and reads level 1 as merely format-converted. Level 2 is the knock-on — srcnet's "science-ready" matches ObsCore's level-2 wording, which is only reachable by shifting level 1 first.
Nothing is remapped here on purpose. dataproduct_type did get a CASE
mapping in the table (srcnet's 'table' becomes ObsCore's 'measurements')
because that is a vocabulary rename with one right answer. Relabelling a
calibration level is not: it asserts something about how the data were
processed, and only the data model owns that claim. So the table reports what
the producer declared, and the TAP_SCHEMA description for the column says as
much rather than reciting ObsCore's vocabulary over values that do not
follow it.
Consequence for clients: a calib_level >= 2 filter over ivoa.obscore
selects srcnet's science-ready and analysis products, which is close to but
not the same as ObsCore's intent. This stays an open item pending a
data-model decision on which side moves.
How the table stays current¶
ivoa.obscore was a view until it was measured against archives that serve
ObsCore from a flat table: the view's join hashed every observation against
every product for a GROUP BY obs_collection, and a result that read
access_url probed srcnet.artifacts once per row. The table costs a
scan-heavy query what a flat table costs, and is kept identical to the
projection it replaced by statement-level triggers on the source tables —
AFTER INSERT/UPDATE/DELETE on srcnet.data_products and
srcnet.artifacts, AFTER UPDATE on srcnet.observations — each running
one INSERT ... SELECT, UPDATE or DELETE per statement over the
statement's transition table. Every path the ODP tables change by is
covered without the ingest code knowing: a notification's upsert, an
amendment of any of the three tables, a document deletion's FK cascade, and
egernia_dataset.seed's bulk loads.
Freshness is read-after-write: the trigger runs in the writer's
transaction, so a row is on ivoa.obscore when the POST that ingested it
returns. There is no refresh and no lag to document.
Disk is a second copy of the projected columns plus its indexes — the
same order of size as srcnet.data_products itself (the pgsphere footprint
is most of a row either way): about 540 MB of table for 500,000 products.
Bootstrap, and migrating from the view¶
The definition — select, encoder, triggers, indexes — is fingerprinted into the table's comment, and the bootstrap compares that fingerprint before it does anything. A pod start that is not a mapping change therefore issues no DDL at all beyond putting back an index that is missing (the seeder sets the GiST indexes aside for a bulk load).
When the fingerprint differs — the first start after upgrading from the view, or a mapping change — the bootstrap builds the table beside the live relation and swaps it in by rename, all inside its one transaction:
LOCK TABLE srcnet.data_products, srcnet.observations, srcnet.artifacts IN SHARE MODE— writers (ingest) wait until the build commits so no row written during the backfill is missed; readers are not affected.CREATE TABLE ivoa.obscore_build AS <select>, then its indexes under_buildnames, then the trigger functions and triggers.DROPthe old view or table,RENAMEthe new table and its indexes into place, comment it. Only this step takes the exclusive lock onivoa.obscore, and it is released at commit a few statements later — a query onivoa.obscorewaits milliseconds, not the build.
Under the pg_sphere backend the build is dominated by the two pgsphere GiST
indexes (the footprint and the position expression), whose build is
insertion-based; on 500,000 products the backfill itself takes seconds and
each GiST index minutes. Ingest is paused for that long, once. Under the
skycell backend the position index is a B-tree on the HEALPix cell and the
footprint index is skycell's GiST over skyregion, both much cheaper to
build.
An ivoa.obscore that already exists, is a table, and does not carry this
service's fingerprint comment is left alone entirely: that deployment is
publishing its own ObsCore, and neither destroying it nor crashing the
bootstrap on it is acceptable.
What a deployment without odp looks like¶
The table, its TAP_SCHEMA registration, the <dataModel> declaration in
/tap/capabilities and the registry record, and the ObsCore entry in
/tap/examples all key off the same condition — the odp plugin being
active — so the REC's rule that the data model may only be declared once
the table with all mandatory columns exists holds structurally. A
deployment running only the software plugin declares nothing and serves
no ivoa schema.