diff --git a/app/services/jsonld.py b/app/services/jsonld.py index 216e682..22dccdd 100644 --- a/app/services/jsonld.py +++ b/app/services/jsonld.py @@ -613,6 +613,11 @@ def map_dataset_to_dcat( "keywords": dataset.keywords.split(",") if dataset.keywords else [], "license": dataset.license, "version": dataset.version, + # The device and shot the dataset is filed under, as plain names rather + # than links, so that other catalogues can filter on them whatever + # addresses they give devices and shots. + "fuel:device": dataset.device_name, + "fuel:shot": dataset.shot_id, } if dataset.persistent_identifier: diff --git a/docs/dcat-jsonld.md b/docs/dcat-jsonld.md index a674e27..b9b58f2 100644 --- a/docs/dcat-jsonld.md +++ b/docs/dcat-jsonld.md @@ -144,6 +144,26 @@ The record has no route of its own: its identifier is the resource's with `#reco resolves to the document that holds it. A catalogue that lists other resources, such as the catalogue of devices, embeds their nodes without their records. +## Device and shot + +A dataset filed under a device, or under one of the device's shots, names them in `fuel:device` +and `fuel:shot`: + +```json +{ + "@type": "dcat:Dataset", + "@id": "https://example.org/datasets/13", + "title": "Equilibrium (Shot 30421)", + "fuel:device": "mast", + "fuel:shot": "30421" +} +``` + +The values are the device's `name` and the shot's ID as plain text, not links to the device and +shot documents. Other catalogues give the same device and shot their own addresses, but can use +the same names, so a search across catalogues can filter on these values. A dataset attached to a +device but no shot has `fuel:device` only, and a global dataset has neither. + ## Dataset vs Distribution FDS follows the [W3C DCAT ontology](https://www.w3.org/TR/vocab-dcat/): a **`dcat:Dataset`** is the abstract metadata entity describing *what* the data is, while a **`dcat:Distribution`** is a concrete physical access path describing *how* to retrieve it. @@ -393,7 +413,7 @@ See [Provenance](provenance.md) for the model behind these terms. | `oa` | `http://www.w3.org/ns/oa#` | Web Annotation, used by `dqv:QualityAnnotation` (`oa:motivatedBy`, `oa:hasBody`) | | `adms` | `http://www.w3.org/ns/adms#` | `adms:identifier`, a persistent identifier registered elsewhere | | `skos` | `http://www.w3.org/2004/02/skos/core#` | `skos:notation`, the value of that identifier | -| `fuel` | `https://w3id.org/fuel/ns#` | Fusion Energy Lexicon, the role concepts used by `prov:hadRole` and `dcat:hadRole` | +| `fuel` | `https://w3id.org/fuel/ns#` | Fusion Energy Lexicon: the role concepts used by `prov:hadRole` and `dcat:hadRole`, and a dataset's `fuel:device` and `fuel:shot` | ## Ontology mapping summary @@ -412,6 +432,8 @@ See [Provenance](provenance.md) for the model behind these terms. | "dataset produced by" | `prov:wasGeneratedBy` | PROV-O | | "activity used input / instrument" | `prov:used` + `prov:qualifiedUsage` (`prov:hadRole`) | PROV-O | | "activity associated with agent" | `prov:wasAssociatedWith` + `prov:qualifiedAssociation` (`prov:hadRole`) | PROV-O | +| Dataset `device_name` | `fuel:device`, as plain text | FuEL | +| Dataset `shot_id` | `fuel:shot`, as plain text | FuEL | | `persistent_identifier` | `adms:identifier` → `adms:Identifier` | ADMS, DCAT-AP | | `issued` (publication date) | `dct:issued` on the resource | Dublin Core, DCAT 3 | | producing Activity's `ended_at` | `prov:generatedAtTime` on the resource | PROV-O | diff --git a/tests/api/v1/test_dataset_router.py b/tests/api/v1/test_dataset_router.py index bad98c6..b0e5a3f 100644 --- a/tests/api/v1/test_dataset_router.py +++ b/tests/api/v1/test_dataset_router.py @@ -913,3 +913,51 @@ def test_temporal_coverage_roundtrip( assert cov["@type"] == "dct:PeriodOfTime" assert cov["startDate"].startswith("2024-03-15T14:00:00") assert cov["endDate"].startswith("2024-03-15T14:30:00") + + +def test_dataset_jsonld_names_its_device_and_shot( + test_client: TestClient, session: Session, admin_user_token: dict +): + DeviceService(session).create( + DeviceCreate(name="MAST", type="Spherical"), user=admin_user + ) + ShotService(session).create( + ShotCreate(id="30421", device_name="MAST"), user=admin_user + ) + datasets = DatasetService(session) + on_shot = datasets.create( + DatasetCreate( + name="efit", + device_name="MAST", + shot_id="30421", + url="s3://mast/30421/efit", + ), + user=admin_user, + ) + on_device = datasets.create( + DatasetCreate(name="geometry", device_name="MAST", url="s3://mast/geometry"), + user=admin_user, + ) + on_nothing = datasets.create( + DatasetCreate(name="constants", url="s3://global/constants"), + user=admin_user, + ) + session.commit() + ld = {**admin_user_token, "Accept": "application/ld+json"} + + shot_node = resource( + test_client.get(f"/v1/datasets/id/{on_shot.id}", headers=ld).json() + ) + device_node = resource( + test_client.get(f"/v1/datasets/id/{on_device.id}", headers=ld).json() + ) + global_node = resource( + test_client.get(f"/v1/datasets/id/{on_nothing.id}", headers=ld).json() + ) + + assert shot_node["fuel:device"] == "mast" + assert shot_node["fuel:shot"] == "30421" + assert device_node["fuel:device"] == "mast" + assert "fuel:shot" not in device_node + assert "fuel:device" not in global_node + assert "fuel:shot" not in global_node