Skip to content

Commit 0baf5a4

Browse files
committed
fix(routines): preserve derivation fields in JSON-LD
1 parent dfe2d5f commit 0baf5a4

7 files changed

Lines changed: 177 additions & 4 deletions

File tree

routines/ontology/ocl-vocabulary.ttl

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,15 +29,18 @@ ocl:algorithm a owl:ObjectProperty .
2929
ocl:output a owl:ObjectProperty .
3030
ocl:input a owl:ObjectProperty .
3131
ocl:source a owl:ObjectProperty .
32+
ocl:signalId a owl:ObjectProperty ; rdfs:range ocl:SoftwareSignal .
3233
ocl:memberPopulation a owl:ObjectProperty .
3334
ocl:exclusion a owl:ObjectProperty .
3435
ocl:dataQualityPolicy a owl:ObjectProperty .
3536
ocl:readyCondition a owl:ObjectProperty .
3637
ocl:unitPolicy a owl:ObjectProperty .
38+
ocl:outputUnit a owl:ObjectProperty .
3739
ocl:outputScope a owl:ObjectProperty .
3840
ocl:resetBehavior a owl:ObjectProperty .
3941

4042
ocl:artifactVersion a owl:DatatypeProperty ; rdfs:range xsd:string .
43+
ocl:localId a owl:DatatypeProperty ; rdfs:range xsd:string .
4144
ocl:canonicalRoutineId a owl:DatatypeProperty ; rdfs:range xsd:string .
4245
ocl:canonicalRoutineRevision a owl:DatatypeProperty ; rdfs:range xsd:integer .
4346
ocl:ontologyPins a owl:DatatypeProperty ; rdfs:range xsd:string .
@@ -68,5 +71,11 @@ ocl:valueKind a owl:DatatypeProperty ; rdfs:range xsd:string .
6871
ocl:memberId a owl:DatatypeProperty ; rdfs:range xsd:string .
6972
ocl:excludedMember a owl:DatatypeProperty ; rdfs:range xsd:string .
7073
ocl:exclusionReason a owl:DatatypeProperty ; rdfs:range xsd:string .
74+
ocl:acceptedStatus a owl:DatatypeProperty ; rdfs:range xsd:string .
75+
ocl:rejectedInputPolicy a owl:DatatypeProperty ; rdfs:range xsd:string .
76+
ocl:minimumValidMembers a owl:DatatypeProperty ; rdfs:range xsd:integer .
7177
ocl:freshnessLimitSeconds a owl:DatatypeProperty ; rdfs:range xsd:decimal .
7278
ocl:timeAlignmentWindowSeconds a owl:DatatypeProperty ; rdfs:range xsd:decimal .
79+
ocl:requireAllInputsInDomain a owl:DatatypeProperty ; rdfs:range xsd:boolean .
80+
ocl:inputUnitsPolicy a owl:DatatypeProperty ; rdfs:range xsd:string .
81+
ocl:conversionPolicy a owl:DatatypeProperty ; rdfs:range xsd:string .

routines/ontology/ontology-pins.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,6 @@
4343
"namespace": "urn:open-control-library:ontology:",
4444
"version": "0.1.0-draft",
4545
"path": "routines/ontology/ocl-vocabulary.ttl",
46-
"sha256": "abb29ebe89fee52f2c73e4ee1335d394e41f95a68f9c7b6d8803d991d17d17a1"
46+
"sha256": "fcc79e8a100bee5fb96ae73472ea7887aeec5ea013e7cbc6f800068aa4451f0b"
4747
}
4848
}

routines/schemas/routine-semantic-profile.schema.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
"canonical_id": "ocl:canonicalRoutineId",
3636
"revision": "ocl:canonicalRoutineRevision",
3737
"ontology_pins": "ocl:ontologyPins",
38+
"id": "ocl:localId",
3839
"connector_roles": {"@id": "ocl:hasConnectorRole", "@container": "@set"},
3940
"connector_id": "ocl:connectorId",
4041
"direction": "ocl:direction",
@@ -65,6 +66,7 @@
6566
"output": "ocl:output",
6667
"inputs": {"@id": "ocl:input", "@container": "@list"},
6768
"source_id": "ocl:sourceId",
69+
"signal_id": {"@id": "ocl:signalId", "@type": "@id"},
6870
"source": "ocl:source",
6971
"value_kind": "ocl:valueKind",
7072
"member_id": "ocl:memberId",
@@ -73,10 +75,17 @@
7375
"member_ids": {"@id": "ocl:excludedMember", "@container": "@set"},
7476
"reason": "ocl:exclusionReason",
7577
"data_quality": "ocl:dataQualityPolicy",
78+
"accepted_statuses": {"@id": "ocl:acceptedStatus", "@container": "@set"},
79+
"rejected_input": "ocl:rejectedInputPolicy",
80+
"minimum_valid_members": "ocl:minimumValidMembers",
7681
"freshness_limit_s": "ocl:freshnessLimitSeconds",
7782
"time_alignment_window_s": "ocl:timeAlignmentWindowSeconds",
7883
"ready_condition": "ocl:readyCondition",
84+
"require_all_inputs_in_domain": "ocl:requireAllInputsInDomain",
7985
"unit_policy": "ocl:unitPolicy",
86+
"output_unit": {"@id": "ocl:outputUnit", "@type": "@id"},
87+
"input_units": "ocl:inputUnitsPolicy",
88+
"conversion": "ocl:conversionPolicy",
8089
"output_scope": "ocl:outputScope",
8190
"reset_behavior": "ocl:resetBehavior"
8291
}

tools/lint/routine_semantics.py

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@
8383
"namespace": LOCAL_NAMESPACE,
8484
"version": "0.1.0-draft",
8585
"path": VOCABULARY_PATH.as_posix(),
86-
"sha256": "abb29ebe89fee52f2c73e4ee1335d394e41f95a68f9c7b6d8803d991d17d17a1",
86+
"sha256": "fcc79e8a100bee5fb96ae73472ea7887aeec5ea013e7cbc6f800068aa4451f0b",
8787
},
8888
}
8989

@@ -161,6 +161,44 @@ def _check_jsonld_safety(value, label, errors, location="$"):
161161
return len(errors) == start
162162

163163

164+
def _schema_property_names(value):
165+
names = set()
166+
if isinstance(value, dict):
167+
properties = value.get("properties")
168+
if isinstance(properties, dict):
169+
names.update(name for name in properties if not name.startswith("@"))
170+
for item in value.values():
171+
names.update(_schema_property_names(item))
172+
elif isinstance(value, list):
173+
for item in value:
174+
names.update(_schema_property_names(item))
175+
return names
176+
177+
178+
def _check_context_coverage(schemas_by_id, errors):
179+
profile_schema = schemas_by_id.get(routine_schemas.SEMANTIC_PROFILE_ID)
180+
if not isinstance(profile_schema, dict):
181+
return
182+
context = (
183+
profile_schema.get("$defs", {}).get("semanticContext", {}).get("const")
184+
)
185+
if not isinstance(context, dict):
186+
return
187+
property_names = set()
188+
for schema_id in (
189+
routine_schemas.SEMANTIC_PROFILE_ID,
190+
routine_schemas.DERIVATION_MANIFEST_ID,
191+
):
192+
schema = schemas_by_id.get(schema_id)
193+
if isinstance(schema, dict):
194+
property_names.update(_schema_property_names(schema))
195+
label = "routines/schemas/routine-semantic-profile.schema.json"
196+
for name in sorted(property_names - set(context)):
197+
errors.append(
198+
f"{label}: semanticContext has no JSON-LD mapping for schema property {name!r}"
199+
)
200+
201+
164202
def _parse_vocabulary(raw, errors):
165203
if raw is None:
166204
return None
@@ -507,14 +545,17 @@ def _check_cross_document(profile, manifest, derived, errors):
507545

508546
def _parse_jsonld(document, relative_path, safe, errors):
509547
if document is None or not safe:
510-
return
548+
return None
511549
label = relative_path.as_posix()
512550
try:
513551
payload = json.dumps(document, ensure_ascii=False, allow_nan=False)
514552
public_id = document.get("@id") if isinstance(document.get("@id"), str) else LOCAL_NAMESPACE
515-
Graph().parse(data=payload, format="json-ld", publicID=public_id)
553+
graph = Graph()
554+
graph.parse(data=payload, format="json-ld", publicID=public_id)
555+
return graph
516556
except Exception as exc:
517557
errors.append(f"{label}: JSON-LD parse failed: {exc}")
558+
return None
518559

519560

520561
def _check_fixture_placement(repo_root, errors):
@@ -565,6 +606,7 @@ def validate(repo_root=REPO_ROOT):
565606
safety[name] = _check_jsonld_safety(document, path.as_posix(), errors)
566607

567608
if schemas_by_id is not None and registry is not None:
609+
_check_context_coverage(schemas_by_id, errors)
568610
for name, schema_id in FIXTURE_SCHEMAS.items():
569611
document = fixtures.get(name)
570612
if document is None:

tools/lint/tests/fixtures/routine_semantics/routine-derivation-manifest.jsonld

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
"canonical_id": "ocl:canonicalRoutineId",
1212
"revision": "ocl:canonicalRoutineRevision",
1313
"ontology_pins": "ocl:ontologyPins",
14+
"id": "ocl:localId",
1415
"connector_roles": {"@id": "ocl:hasConnectorRole", "@container": "@set"},
1516
"connector_id": "ocl:connectorId",
1617
"direction": "ocl:direction",
@@ -41,6 +42,7 @@
4142
"output": "ocl:output",
4243
"inputs": {"@id": "ocl:input", "@container": "@list"},
4344
"source_id": "ocl:sourceId",
45+
"signal_id": {"@id": "ocl:signalId", "@type": "@id"},
4446
"source": "ocl:source",
4547
"value_kind": "ocl:valueKind",
4648
"member_id": "ocl:memberId",
@@ -49,10 +51,17 @@
4951
"member_ids": {"@id": "ocl:excludedMember", "@container": "@set"},
5052
"reason": "ocl:exclusionReason",
5153
"data_quality": "ocl:dataQualityPolicy",
54+
"accepted_statuses": {"@id": "ocl:acceptedStatus", "@container": "@set"},
55+
"rejected_input": "ocl:rejectedInputPolicy",
56+
"minimum_valid_members": "ocl:minimumValidMembers",
5257
"freshness_limit_s": "ocl:freshnessLimitSeconds",
5358
"time_alignment_window_s": "ocl:timeAlignmentWindowSeconds",
5459
"ready_condition": "ocl:readyCondition",
60+
"require_all_inputs_in_domain": "ocl:requireAllInputsInDomain",
5561
"unit_policy": "ocl:unitPolicy",
62+
"output_unit": {"@id": "ocl:outputUnit", "@type": "@id"},
63+
"input_units": "ocl:inputUnitsPolicy",
64+
"conversion": "ocl:conversionPolicy",
5665
"output_scope": "ocl:outputScope",
5766
"reset_behavior": "ocl:resetBehavior"
5867
},

tools/lint/tests/fixtures/routine_semantics/routine-semantic-profile.jsonld

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
"canonical_id": "ocl:canonicalRoutineId",
1212
"revision": "ocl:canonicalRoutineRevision",
1313
"ontology_pins": "ocl:ontologyPins",
14+
"id": "ocl:localId",
1415
"connector_roles": {"@id": "ocl:hasConnectorRole", "@container": "@set"},
1516
"connector_id": "ocl:connectorId",
1617
"direction": "ocl:direction",
@@ -41,6 +42,7 @@
4142
"output": "ocl:output",
4243
"inputs": {"@id": "ocl:input", "@container": "@list"},
4344
"source_id": "ocl:sourceId",
45+
"signal_id": {"@id": "ocl:signalId", "@type": "@id"},
4446
"source": "ocl:source",
4547
"value_kind": "ocl:valueKind",
4648
"member_id": "ocl:memberId",
@@ -49,10 +51,17 @@
4951
"member_ids": {"@id": "ocl:excludedMember", "@container": "@set"},
5052
"reason": "ocl:exclusionReason",
5153
"data_quality": "ocl:dataQualityPolicy",
54+
"accepted_statuses": {"@id": "ocl:acceptedStatus", "@container": "@set"},
55+
"rejected_input": "ocl:rejectedInputPolicy",
56+
"minimum_valid_members": "ocl:minimumValidMembers",
5257
"freshness_limit_s": "ocl:freshnessLimitSeconds",
5358
"time_alignment_window_s": "ocl:timeAlignmentWindowSeconds",
5459
"ready_condition": "ocl:readyCondition",
60+
"require_all_inputs_in_domain": "ocl:requireAllInputsInDomain",
5561
"unit_policy": "ocl:unitPolicy",
62+
"output_unit": {"@id": "ocl:outputUnit", "@type": "@id"},
63+
"input_units": "ocl:inputUnitsPolicy",
64+
"conversion": "ocl:conversionPolicy",
5665
"output_scope": "ocl:outputScope",
5766
"reset_behavior": "ocl:resetBehavior"
5867
},

tools/lint/tests/test_routine_semantics.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
from pathlib import Path
99
from unittest import mock
1010

11+
from rdflib import Literal, Namespace, URIRef
12+
1113
from tools.lint import routine_semantics
1214

1315

@@ -23,6 +25,8 @@
2325
for name in routine_semantics.FIXTURE_SCHEMAS
2426
)
2527
PROFILE_PATH, MANIFEST_PATH = FIXTURE_FILES
28+
OCL = Namespace(routine_semantics.LOCAL_NAMESPACE)
29+
QUDT_UNIT = Namespace("http://qudt.org/vocab/unit/")
2630

2731

2832
class RoutineSemanticTests(unittest.TestCase):
@@ -57,6 +61,20 @@ def mutate(self, relative_path, change):
5761
change(value)
5862
self.write_json(relative_path, value)
5963

64+
def parse_jsonld(self, relative_path):
65+
document = self.read_json(relative_path)
66+
errors = []
67+
safe = routine_semantics._check_jsonld_safety(
68+
document, relative_path, errors
69+
)
70+
graph = routine_semantics._parse_jsonld(
71+
document, Path(relative_path), safe, errors
72+
)
73+
self.assertEqual(errors, [])
74+
if graph is None:
75+
self.fail(f"{relative_path} did not produce an RDF graph")
76+
return document, graph
77+
6078
def assert_error(self, expected):
6179
errors = routine_semantics.validate(self.root)
6280
self.assertTrue(
@@ -192,6 +210,83 @@ def test_contexts_and_imports_are_rejected_before_jsonld_parsing(self):
192210
self.assert_error(expected)
193211
(self.root / PROFILE_PATH).write_bytes(original)
194212

213+
def test_context_coverage_rejects_an_unmapped_schema_property(self):
214+
schema_path = "routines/schemas/routine-semantic-profile.schema.json"
215+
self.mutate(
216+
schema_path,
217+
lambda value: value["$defs"]["semanticContext"]["const"].pop("id"),
218+
)
219+
for fixture_path in (PROFILE_PATH, MANIFEST_PATH):
220+
self.mutate(fixture_path, lambda value: value["@context"].pop("id"))
221+
self.assert_error("semanticContext has no JSON-LD mapping for schema property 'id'")
222+
223+
def test_derivation_jsonld_preserves_ids_and_policy_values(self):
224+
manifest, graph = self.parse_jsonld(MANIFEST_PATH)
225+
226+
expected_ids = {
227+
"zone_air_temperature_max",
228+
"north_zone_temperature",
229+
"south_zone_temperature",
230+
"service_zone_temperature",
231+
"north-zone",
232+
"south-zone",
233+
"service-zone",
234+
"exclude_service_zone",
235+
}
236+
self.assertEqual(
237+
{str(value) for value in graph.objects(None, OCL.localId)}, expected_ids
238+
)
239+
240+
manifest_id = URIRef(manifest["@id"])
241+
data_quality = graph.value(manifest_id, OCL.dataQualityPolicy)
242+
self.assertIsNotNone(data_quality)
243+
self.assertIn((data_quality, OCL.acceptedStatus, Literal("good")), graph)
244+
self.assertIn(
245+
(data_quality, OCL.rejectedInputPolicy, Literal("exclude-member")),
246+
graph,
247+
)
248+
self.assertIn((data_quality, OCL.minimumValidMembers, Literal(2)), graph)
249+
250+
ready = graph.value(manifest_id, OCL.readyCondition)
251+
self.assertIsNotNone(ready)
252+
self.assertIn((ready, OCL.minimumValidMembers, Literal(2)), graph)
253+
self.assertIn(
254+
(ready, OCL.requireAllInputsInDomain, Literal(False)), graph
255+
)
256+
257+
unit_policy = graph.value(manifest_id, OCL.unitPolicy)
258+
self.assertIsNotNone(unit_policy)
259+
self.assertIn((unit_policy, OCL.outputUnit, QUDT_UNIT.DEG_C), graph)
260+
self.assertIn(
261+
(unit_policy, OCL.inputUnitsPolicy, Literal("same-as-output")), graph
262+
)
263+
self.assertIn(
264+
(unit_policy, OCL.conversionPolicy, Literal("none")), graph
265+
)
266+
267+
def test_software_source_signal_id_is_preserved_as_an_iri(self):
268+
signal_id = "urn:open-control-library:software-signal:north-zone-temperature"
269+
self.mutate(
270+
MANIFEST_PATH,
271+
lambda value: value["inputs"][0].update(
272+
source={
273+
"kind": "software-signal",
274+
"signal_id": signal_id,
275+
"local_class": "ocl:SoftwareSignal",
276+
"value_kind": "real",
277+
"qudt_unit": "unit:DEG_C",
278+
"member_id": "north-zone",
279+
}
280+
),
281+
)
282+
self.assertEqual(routine_semantics.validate(self.root), [])
283+
284+
manifest, graph = self.parse_jsonld(MANIFEST_PATH)
285+
input_id = URIRef(manifest["inputs"][0]["@id"])
286+
source = graph.value(input_id, OCL.source)
287+
self.assertIsNotNone(source)
288+
self.assertIn((source, OCL.signalId, URIRef(signal_id)), graph)
289+
195290
def test_ontology_pin_constants_and_local_vocabulary_hash_are_enforced(self):
196291
pin_path = "routines/ontology/ontology-pins.json"
197292
pin_original = (self.root / pin_path).read_bytes()

0 commit comments

Comments
 (0)