Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 24 additions & 18 deletions buildingmotif/dataclasses/library.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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(
Expand Down
23 changes: 18 additions & 5 deletions buildingmotif/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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))
Expand All @@ -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

Expand Down
1 change: 1 addition & 0 deletions docs/_toc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ parts:
- caption: Explainations
chapters:
- file: explanations/ingresses.md
- file: explanations/shapes-and-templates.md
- caption: Appendix
chapters:
- file: bibliography.md
182 changes: 182 additions & 0 deletions docs/explanations/shapes-and-templates.md
Original file line number Diff line number Diff line change
@@ -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: <https://brickschema.org/schema/Brick#> .
@prefix sh: <http://www.w3.org/ns/shacl#> .
@prefix owl: <http://www.w3.org/2002/07/owl#> .
@prefix : <urn:example/> .

: 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 ;
] ;
.
```

<details>

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: <https://brickschema.org/schema/Brick#> .
@prefix sh: <http://www.w3.org/ns/shacl#> .
@prefix owl: <http://www.w3.org/2002/07/owl#> .
@prefix : <urn:example/> .

: 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 ;
] ;
.
""")
```

</details>

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.
11 changes: 11 additions & 0 deletions tests/unit/dataclasses/test_library.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions tests/unit/fixtures/bad_shape_template.ttl
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
@prefix brick: <https://brickschema.org/schema/Brick#> .
@prefix owl: <http://www.w3.org/2002/07/owl#> .
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
@prefix sh: <http://www.w3.org/ns/shacl#> .
@prefix : <urn:shape1/> .

: 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;
] ;
.
29 changes: 29 additions & 0 deletions tests/unit/fixtures/libary-shape-test/shape.ttl
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
@prefix brick: <https://brickschema.org/schema/Brick#> .
@prefix owl: <http://www.w3.org/2002/07/owl#> .
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
@prefix sh: <http://www.w3.org/ns/shacl#> .
@prefix : <urn:shape/> .

: 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;
] ;
.

26 changes: 26 additions & 0 deletions tests/unit/fixtures/library-shape-test/shape.ttl
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
@prefix brick: <https://brickschema.org/schema/Brick#> .
@prefix owl: <http://www.w3.org/2002/07/owl#> .
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
@prefix sh: <http://www.w3.org/ns/shacl#> .
@prefix : <urn:shape/> .

: 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 ;
] ;
.
Loading