diff --git a/buildingmotif/dataclasses/library.py b/buildingmotif/dataclasses/library.py index 99e615d65..bafac285b 100644 --- a/buildingmotif/dataclasses/library.py +++ b/buildingmotif/dataclasses/library.py @@ -260,24 +260,8 @@ def _load_from_ontology( lib = cls.create(ontology_name, overwrite=overwrite) - class_candidates = set(ontology.subjects(rdflib.RDF.type, rdflib.OWL.Class)) - shape_candidates = set(ontology.subjects(rdflib.RDF.type, rdflib.SH.NodeShape)) - candidates = class_candidates.intersection(shape_candidates) - - # stores the lookup from template *names* to template *ids* - # this is necessary because while we know the *name* of the dependee templates - # for each dependent template, we don't know the *id* of the dependee templates, - # which is necessary to populate the dependencies - template_id_lookup: Dict[str, int] = {} - dependency_cache: Dict[int, List[Dict[Any, Any]]] = {} - for candidate in candidates: - assert isinstance(candidate, rdflib.URIRef) - partial_body, deps = get_template_parts_from_shape(candidate, ontology) - templ = lib.create_template(str(candidate), partial_body) - dependency_cache[templ.id] = deps - template_id_lookup[str(candidate)] = templ.id - - lib._resolve_template_dependencies(template_id_lookup, dependency_cache) + # infer shapes from any class/nodeshape candidates in the graph + lib._infer_shapes_from_graph(ontology) # load the ontology graph as a shape_collection shape_col_id = lib.get_shape_collection().id @@ -287,6 +271,26 @@ def _load_from_ontology( return lib + def _infer_shapes_from_graph(self, graph: rdflib.Graph): + """Infer shapes from a graph and add them to this library. + + :param graph: graph to infer shapes from + :type graph: rdflib.Graph + """ + class_candidates = set(graph.subjects(rdflib.RDF.type, rdflib.OWL.Class)) + shape_candidates = set(graph.subjects(rdflib.RDF.type, rdflib.SH.NodeShape)) + candidates = class_candidates.intersection(shape_candidates) + template_id_lookup: Dict[str, int] = {} + dependency_cache: Dict[int, List[Dict[Any, Any]]] = {} + for candidate in candidates: + assert isinstance(candidate, rdflib.URIRef) + partial_body, deps = get_template_parts_from_shape(candidate, graph) + templ = self.create_template(str(candidate), partial_body) + dependency_cache[templ.id] = deps + template_id_lookup[str(candidate)] = templ.id + + self._resolve_template_dependencies(template_id_lookup, dependency_cache) + def _load_shapes_from_directory(self, directory: pathlib.Path): """Helper method to read all graphs in the given directory into this library. @@ -305,6 +309,8 @@ def _load_shapes_from_directory(self, directory: pathlib.Path): f"Could not parse file {filename}: {e}" ) raise e + # infer shapes from any class/nodeshape candidates in the graph + self._infer_shapes_from_graph(shape_col.graph) @classmethod def _load_from_directory( diff --git a/buildingmotif/utils.py b/buildingmotif/utils.py index f7ee57f60..978da128b 100644 --- a/buildingmotif/utils.py +++ b/buildingmotif/utils.py @@ -207,6 +207,8 @@ def get_template_parts_from_shape( pshapes = shape_graph.objects(subject=shape_name, predicate=SH["property"]) for pshape in pshapes: property_path = shape_graph.value(pshape, SH["path"]) + if property_path is None: + raise Exception(f"no sh:path detected on {shape_name}") # TODO: expand otypes to include sh:in, sh:or, or no datatype at all! otypes = list( shape_graph.objects( @@ -232,11 +234,16 @@ def get_template_parts_from_shape( (path, otype, mincount) = property_path, otypes[0], mincounts[0] assert isinstance(mincount, Literal) - for _ in range(int(mincount)): - param = _gensym() + param_name = shape_graph.value(pshape, SH["name"]) + + for num in range(int(mincount)): + if param_name is not None: + param = PARAM[f"{param_name}{num}"] + else: + param = _gensym() body.add((root_param, path, param)) - deps.append({"template": otype, "args": {"name": param}}) - # body.add((param, RDF.type, otype)) + deps.append({"template": str(otype), "args": {"name": param}}) + body.add((param, RDF.type, otype)) if (shape_name, RDF.type, OWL.Class) in shape_graph: body.add((root_param, RDF.type, shape_name)) @@ -245,9 +252,15 @@ def get_template_parts_from_shape( for cls in classes: body.add((root_param, RDF.type, cls)) + classes = shape_graph.objects(shape_name, SH["targetClass"]) + for cls in classes: + body.add((root_param, RDF.type, cls)) + nodes = shape_graph.objects(shape_name, SH["node"]) for node in nodes: - deps.append({"template": node, "args": {"name": "name"}}) # tie to root param + deps.append( + {"template": str(node), "args": {"name": "name"}} + ) # tie to root param return body, deps diff --git a/docs/_toc.yml b/docs/_toc.yml index 10f8045db..6eda9297c 100644 --- a/docs/_toc.yml +++ b/docs/_toc.yml @@ -23,6 +23,7 @@ parts: - caption: Explainations chapters: - file: explanations/ingresses.md + - file: explanations/shapes-and-templates.md - caption: Appendix chapters: - file: bibliography.md diff --git a/docs/explanations/shapes-and-templates.md b/docs/explanations/shapes-and-templates.md new file mode 100644 index 000000000..412ae75c5 --- /dev/null +++ b/docs/explanations/shapes-and-templates.md @@ -0,0 +1,182 @@ +--- +jupytext: + cell_metadata_filter: -all + formats: md:myst + text_representation: + extension: .md + format_name: myst +kernelspec: + display_name: Python 3 + language: python + name: python3 +--- + +# Shapes and Templates + +Shapes and Templates interact in interesting ways in BuildingMOTIF. +In this document, we explain the utility and function of these interactions. + +Recall that a **Shape** (SHACL shape) is a set of conditions and constraints over RDF graphs, and +a **Template** is a function that generates an RDF graph. + +## Converting Shapes to Templates + +BuildingMOTIF automatically converts shapes to templates. +Evaluating the resulting template will generate a graph that validates against the shape. + +When BuildingMOTIF loads a Library, it makes an attempt to find any shapes defined within it. +The way this happens depends on how the library is loaded: +- *Loading library from directory or git repository*: BuildingMOTIF searches for any RDF files in the directory (recursively) and loads them into a Shape Collection; loads any instances of `sh:NodeShape` in the union of these RDF files +- *Loading library from ontology file*: loads all instances of `sh:NodeShape` in the provided graphc + +```{important} +BuildingMOTIF *only* loads shapes which are instances of *both* `sh:NodeShape` **and** `owl:Class`. The assumption is that `owl:Class`-ified shapes could be "instantiated". +``` + +Each shape is "decompiled" into components from which a Template can be constructed. +The implementation of this decompilation is in the [`get_template_parts_from_shape`](/reference/apidoc/_autosummary/buildingmotif.utils.html#buildingmotif.utils.get_template_parts_from_shape) method. +BuildingMOTIF currently recognizes the following SHACL properties: +- `sh:property` +- `sh:qualifiedValueShape` +- `sh:node` +- `sh:class` +- `sh:targetClass` +- `sh:datatype` +- `sh:minCount` / `sh:qualifiedMinCount` +- `sh:maxCount` / `sh:qualifiedMaxCount` + +BuildingMOTIF currently uses the name of the SHACL shape as the name of the generated Template. +All other parameters (i.e., nodes corresponding to `sh:property`) are given invented names *unless* + there is a `sh:name` attribute on the property shape. + +### Example + +Consider the following shape which has been loaded into BuildingMOTIF as part of a Library: + +```ttl +# myshapes.ttl +@prefix brick: . +@prefix sh: . +@prefix owl: . +@prefix : . + +: a owl:Ontology . + +:vav a sh:NodeShape, owl:Class ; + sh:targetClass brick:Terminal_Unit ; + sh:property [ + sh:path brick:hasPart ; + sh:qualifiedValueShape [ sh:node :heating-coil ] ; + sh:name "hc" ; + sh:qualifiedMinCount 1 ; + ] ; + sh:property [ + sh:path brick:hasPoint ; + sh:qualifiedValueShape [ sh:class brick:Supply_Air_Flow_Sensor ] ; + sh:qualifiedMinCount 1 ; + ] ; + sh:property [ + sh:path brick:hasPoint ; + sh:qualifiedValueShape [ sh:class brick:Supply_Air_Temperature_Sensor ] ; + sh:name "sat" ; + sh:qualifiedMinCount 1 ; + ] ; +. + +:heating-coil a sh:NodeShape, owl:Class ; + sh:targetClass brick:Heating_Coil ; + sh:property [ + sh:path brick:hasPoint ; + sh:qualifiedValueShape [ sh:class brick:Position_Command ] ; + sh:name "damper_pos" ; # will be used as the parameter name + sh:qualifiedMinCount 1 ; + ] ; +. +``` + +
+ +This code creates `myshapes.ttl` for you in the current directory. + +```{code-cell} python3 +with open("myshapes.ttl", "w") as f: + f.write(""" +@prefix brick: . +@prefix sh: . +@prefix owl: . +@prefix : . + +: a owl:Ontology . + +:vav a sh:NodeShape, owl:Class ; + sh:targetClass brick:Terminal_Unit ; + sh:property [ + sh:path brick:hasPart ; + sh:qualifiedValueShape [ sh:node :heating-coil ] ; + sh:name "hc" ; + sh:qualifiedMinCount 1 ; + ] ; + sh:property [ + sh:path brick:hasPoint ; + sh:qualifiedValueShape [ sh:class brick:Supply_Air_Flow_Sensor ] ; + sh:qualifiedMinCount 1 ; + ] ; + sh:property [ + sh:path brick:hasPoint ; + sh:qualifiedValueShape [ sh:class brick:Supply_Air_Temperature_Sensor ] ; + sh:name "sat" ; + sh:qualifiedMinCount 1 ; + ] ; +. + +:heating-coil a sh:NodeShape, owl:Class ; + sh:targetClass brick:Heating_Coil ; + sh:property [ + sh:path brick:hasPoint ; + sh:qualifiedValueShape [ sh:class brick:Position_Command ] ; + sh:name "damper_pos" ; # will be used as the parameter name + sh:qualifiedMinCount 1 ; + ] ; +. +""") +``` + +
+ +If this was in a file `myshapes.ttl`, we would load it into BuildingMOTIF as follows: + +```{code-cell} python3 +from buildingmotif import BuildingMOTIF +from buildingmotif.dataclasses import Library + +# in-memory instance +bm = BuildingMOTIF("sqlite://") + +# load library +brick = Library.load(ontology_graph="https://github.com/BrickSchema/Brick/releases/download/nightly/Brick.ttl") +lib = Library.load(ontology_graph="myshapes.ttl") +``` + +Once the library has been loaded, all of the shapes have been turned into templates. +We can load the template by name (using its *full URI* from the shape) as if it were +defined explicitly: + +```{code-cell} python3 +# reading the template out by name +template = lib.get_template_by_name("urn:example/vav") + +# dump the body of the template +print(template.body.serialize()) +``` + +As with other templates, we often want to *inline* all dependencies to get a sense of what metadata will be added to the graph. + +```{code-cell} python3 +# reading the template out by name +template = lib.get_template_by_name("urn:example/vav").inline_dependencies() + +# dump the body of the template +print(template.body.serialize()) +``` + +Observe that the generated template uses the `sh:name` property of each property shape to inform the paramter name. If this is not provided (e.g. for the `brick:Supply_Air_Flow_Sensor` property shape), then a generated parameter will be used. diff --git a/tests/unit/dataclasses/test_library.py b/tests/unit/dataclasses/test_library.py index cc84efde4..719197546 100644 --- a/tests/unit/dataclasses/test_library.py +++ b/tests/unit/dataclasses/test_library.py @@ -76,6 +76,17 @@ def test_load_library_from_ontology(bm: BuildingMOTIF): assert len(shapeg.graph) > 1 +def test_load_library_from_ontology_with_error(bm: BuildingMOTIF): + with pytest.raises(Exception): + Library.load(ontology_graph="tests/unit/fixtures/bad_shape_template.ttl") + + +def test_load_shapes_with_directory_library(bm: BuildingMOTIF): + lib = Library.load(directory="tests/unit/fixtures/library-shape-test") + assert lib is not None + assert len(lib.get_templates()) == 2 + + def test_load_library_from_directory(bm: BuildingMOTIF): lib = Library.load(directory="tests/unit/fixtures/templates") assert lib is not None diff --git a/tests/unit/fixtures/bad_shape_template.ttl b/tests/unit/fixtures/bad_shape_template.ttl new file mode 100644 index 000000000..99a71352d --- /dev/null +++ b/tests/unit/fixtures/bad_shape_template.ttl @@ -0,0 +1,18 @@ +@prefix brick: . +@prefix owl: . +@prefix rdf: . +@prefix rdfs: . +@prefix sh: . +@prefix : . + +: a owl:Ontology . + +:vav_shape a owl:Class, sh:NodeShape ; + sh:targetClass brick:VAV ; + sh:property [ + # missing sh:path! + sh:qualifiedValueShape [ sh:class brick:Air_Flow_Sensor ] ; + sh:qualifiedMinCount 1 ; + sh:minCount 1; + ] ; +. diff --git a/tests/unit/fixtures/libary-shape-test/shape.ttl b/tests/unit/fixtures/libary-shape-test/shape.ttl new file mode 100644 index 000000000..48a7fbcad --- /dev/null +++ b/tests/unit/fixtures/libary-shape-test/shape.ttl @@ -0,0 +1,29 @@ +@prefix brick: . +@prefix owl: . +@prefix rdf: . +@prefix rdfs: . +@prefix sh: . +@prefix : . + +: a owl:Ontology . + +:vav_shape a owl:Class, sh:NodeShape ; + sh:targetClass brick:VAV ; + sh:property [ + sh:path brick:hasPoint ; + sh:qualifiedValueShape [ sh:class brick:Air_Flow_Sensor ] ; + sh:qualifiedMinCount 1 ; + sh:minCount 1; + ] ; +. + +:tu_shape a owl:Class, sh:NodeShape ; + sh:targetClass brick:Terminal_Unit ; + sh:property [ + sh:path brick:hasPoint ; + sh:qualifiedValueShape [ sh:class brick:Temperature_Sensor ] ; + sh:qualifiedMinCount 1 ; + sh:minCount 1; + ] ; +. + diff --git a/tests/unit/fixtures/library-shape-test/shape.ttl b/tests/unit/fixtures/library-shape-test/shape.ttl new file mode 100644 index 000000000..28ba5330c --- /dev/null +++ b/tests/unit/fixtures/library-shape-test/shape.ttl @@ -0,0 +1,26 @@ +@prefix brick: . +@prefix owl: . +@prefix rdf: . +@prefix rdfs: . +@prefix sh: . +@prefix : . + +: a owl:Ontology . + +:vav_shape a owl:Class, sh:NodeShape ; + sh:targetClass brick:VAV ; + sh:property [ + sh:path brick:hasPoint ; + sh:qualifiedValueShape [ sh:class brick:Air_Flow_Sensor ] ; + sh:qualifiedMinCount 1 ; + ] ; +. + +:tu_shape a owl:Class, sh:NodeShape ; + sh:targetClass brick:Terminal_Unit ; + sh:property [ + sh:path brick:hasPoint ; + sh:qualifiedValueShape [ sh:class brick:Temperature_Sensor ] ; + sh:qualifiedMinCount 1 ; + ] ; +. diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py index b9919986b..8693c7f81 100644 --- a/tests/unit/test_utils.py +++ b/tests/unit/test_utils.py @@ -53,7 +53,7 @@ def test_get_template_parts_from_shape(): ) body, deps = get_template_parts_from_shape(MODEL["shape1"], shape_graph) assert len(deps) == 1 - assert deps[0]["template"] == BRICK.Temperature_Sensor + assert deps[0]["template"] == str(BRICK.Temperature_Sensor) assert list(deps[0]["args"].keys()) == ["name"] assert (PARAM["name"], A, MODEL["shape1"]) in body # assert (PARAM['name'], BRICK.hasPoint,