From 2021db77f506b3cb703ca6f3df21466ecc47745f Mon Sep 17 00:00:00 2001 From: Nathan Cummings Date: Mon, 19 Oct 2026 11:37:38 +0100 Subject: [PATCH] feat: Name a dataset's device and shot in its JSON-LD A dataset's JSON-LD now carries fuel:device and fuel:shot: the name of the device it is filed under and the ID of its shot, as plain text. Another catalogue can filter on them without knowing the addresses FDS gives devices and shots. A dataset on a device but no shot has fuel:device only, and a global dataset has neither. Co-Authored-By: Claude Opus 5.5 (1M context) --- app/services/jsonld.py | 5 +++ docs/dcat-jsonld.md | 24 ++++++++++++++- tests/api/v1/test_dataset_router.py | 48 +++++++++++++++++++++++++++++ 3 files changed, 76 insertions(+), 1 deletion(-) diff --git a/app/services/jsonld.py b/app/services/jsonld.py index 5a3c58e..be5c56b 100644 --- a/app/services/jsonld.py +++ b/app/services/jsonld.py @@ -612,6 +612,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