Skip to content

Commit

Permalink
Add infer_templates method on shape collection (#361)
Browse files Browse the repository at this point in the history
* add custom library/template not found exceptions

* modelnotfound

* add errors!

* add infer_templates method

* add more docs

* ad inline method for qualified value shape

* fix API error detection

* fix exception

* use the correct error

* Update buildingmotif/utils.py

Co-authored-by: Matt Steen <MatthewSteen@users.noreply.github.com>

* fix import of Library dataclass

---------

Co-authored-by: Matt Steen <MatthewSteen@users.noreply.github.com>
  • Loading branch information
gtfierro and MatthewSteen authored Feb 7, 2025
1 parent 7cb9a55 commit 393d290
Show file tree
Hide file tree
Showing 4 changed files with 117 additions and 52 deletions.
55 changes: 6 additions & 49 deletions buildingmotif/dataclasses/library.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,7 @@
from buildingmotif.dataclasses.template import Template
from buildingmotif.schemas import validate_libraries_yaml
from buildingmotif.template_compilation import compile_template_spec
from buildingmotif.utils import (
copy_graph,
get_ontology_files,
get_template_parts_from_shape,
shacl_inference,
skip_uri,
)
from buildingmotif.utils import get_ontology_files, shacl_inference, skip_uri

if TYPE_CHECKING:
from buildingmotif import BuildingMOTIF
Expand Down Expand Up @@ -282,54 +276,17 @@ def _load_from_ontology(

lib = cls.create(ontology_name, overwrite=overwrite)

if infer_templates:
# infer shapes from any class/nodeshape candidates in the graph
lib._infer_templates_from_graph(ontology)

# load the ontology graph as a shape_collection
shape_col_id = lib.get_shape_collection().id
assert shape_col_id is not None # should always pass
shape_col = ShapeCollection.load(shape_col_id)
shape_col.add_graph(ontology)

return lib

def _infer_templates_from_graph(self, graph: rdflib.Graph):
"""Infer templates from a graph (by interpreting shapes) and add them to this library.
:param graph: graph to infer templates from
:type graph: rdflib.Graph
"""
# add all imports to the same graph so we can resolve everything
imports_closure = copy_graph(graph)
# import dependencies into 'graph'
# get all imports from the graph
for dependency in graph.objects(predicate=rdflib.OWL.imports):
# attempt to load from BuildingMOTIF
try:
lib = Library.load(name=str(dependency))
imports_closure += lib.get_shape_collection().graph
except Exception as e: # TODO: replace with a more specific exception
logging.warning(
f"An ontology could not resolve a dependency on {dependency} ({e}). Check this is loaded into BuildingMOTIF"
)
continue
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)
# TODO: mincount 0 (or unspecified) should be optional args on the generated template
partial_body, deps = get_template_parts_from_shape(
candidate, imports_closure
)
templ = self.create_template(str(candidate), partial_body)
dependency_cache[templ.id] = deps
template_id_lookup[str(candidate)] = templ.id
if infer_templates:
# infer shapes from any class/nodeshape candidates in the graph
shape_col.infer_templates(lib)

self._resolve_template_dependencies(template_id_lookup, dependency_cache)
return lib

def _load_shapes_from_directory(
self,
Expand Down Expand Up @@ -364,7 +321,7 @@ def _load_shapes_from_directory(
)
# infer shapes from any class/nodeshape candidates in the graph
if infer_templates:
self._infer_templates_from_graph(shape_col.graph)
shape_col.infer_templates(self)

@classmethod
def _load_from_directory(
Expand Down
45 changes: 43 additions & 2 deletions buildingmotif/dataclasses/shape_collection.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from collections import defaultdict
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Dict, List, Optional, Set, Tuple, Union
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Set, Tuple, Union

import rdflib
from pyshacl.helper.path_helper import shacl_path_to_sparql_path
Expand All @@ -14,10 +14,11 @@

from buildingmotif import get_building_motif
from buildingmotif.namespaces import BMOTIF, OWL, SH
from buildingmotif.utils import Triple, copy_graph
from buildingmotif.utils import Triple, copy_graph, get_template_parts_from_shape

if TYPE_CHECKING:
from buildingmotif import BuildingMOTIF
from buildingmotif.dataclasses import Library

ONTOLOGY_FILE = (
Path(__file__).resolve().parents[1] / "resources" / "building_motif_ontology.ttl"
Expand Down Expand Up @@ -180,6 +181,46 @@ def _get_included_domains(cls, domain: URIRef) -> List[URIRef]:

return results

def infer_templates(self, library: "Library") -> None:
"""Infer templates from the graph in this ShapeCollection and add them to the given library.
:param library: The library to add inferred templates to
:type library: Library
"""
# we need to do the Library import here to avoid circular imports
from buildingmotif.dataclasses.library import Library

imports_closure = copy_graph(self.graph)
for dependency in self.graph.objects(predicate=rdflib.OWL.imports):
try:
lib = Library.load(name=str(dependency))
imports_closure += lib.get_shape_collection().graph
except Exception as e:
logging.warning(
f"An ontology could not resolve a dependency on {dependency} ({e}). Check this is loaded into BuildingMOTIF"
)
continue

class_candidates = set(self.graph.subjects(rdflib.RDF.type, rdflib.OWL.Class))
shape_candidates = set(
self.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, imports_closure
)
templ = library.create_template(str(candidate), partial_body)
dependency_cache[templ.id] = deps
template_id_lookup[str(candidate)] = templ.id

library._resolve_template_dependencies(template_id_lookup, dependency_cache)

def get_shapes_of_definition_type(
self, definition_type: URIRef, include_labels=False
) -> Union[List[URIRef], List[Tuple[URIRef, str]]]:
Expand Down
20 changes: 20 additions & 0 deletions buildingmotif/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -538,6 +538,25 @@ def _inline_sh_and(sg: Graph):
sg.add((parent, p, o))


def _inline_sh_qualified_value_shape(sg: Graph):
"""
This detects the use of 'sh:qualifiedValueShape' on SHACL PropertyShapes and inlines
all of the included shapes
"""
q = """
PREFIX sh: <http://www.w3.org/ns/shacl#>
SELECT ?parent ?child WHERE {
?parent a sh:PropertyShape ;
sh:qualifiedValueShape ?child .
}"""
for row in sg.query(q):
parent, child = row # type: ignore
sg.remove((parent, SH["qualifiedValueShape"], child))
pobjs = sg.predicate_objects(child)
for (p, o) in pobjs:
sg.add((parent, p, o))


def rewrite_shape_graph(g: Graph) -> Graph:
"""
Rewrites the input graph to make the resulting validation report more useful.
Expand All @@ -555,6 +574,7 @@ def rewrite_shape_graph(g: Graph) -> Graph:
_inline_sh_and(sg)
# make sure to handle sh:node *after* sh:and
_inline_sh_node(sg)
_inline_sh_qualified_value_shape(sg)
return sg


Expand Down
49 changes: 48 additions & 1 deletion docs/explanations/shapes-and-templates.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ a **Template** is a function that generates an RDF graph.

## Converting Shapes to Templates

BuildingMOTIF automatically converts shapes to templates.
### When Loading a Library

BuildingMOTIF can automatically convert shapes to templates when loading a Library.
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.
Expand Down Expand Up @@ -49,6 +51,51 @@ BuildingMOTIF currently uses the name of the SHACL shape as the name of the gene
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.

This feature can be disabled by setting `infer_templates=False` when calling `Library.load`

### From Shape Collections

It is also possible to convert the shapes defined in a Shape Collection to templates.
This is done by calling the `infer_templates` method on the Shape Collection.
If `infer_templates` is True when calling `Library.load`, then BuildingMOTIF will automatically call `infer_templates` on the Shape Collection
within that Library.

Being able to call `infer_templates` on a Shape Collection is useful when you have
a graph of shapes that you programmatically created or loaded without packaging them
in a Library.

```{code-cell} python3
from buildingmotif import BuildingMOTIF
from buildingmotif.dataclasses import Library, ShapeCollection
# in-memory instance
bm = BuildingMOTIF("sqlite://")
my_shapes_source = """
@prefix sh: <http://www.w3.org/ns/shacl#> .
@prefix owl: <http://www.w3.org/2002/07/owl#> .
@prefix brick: <https://brickschema.org/schema/Brick#> .
@prefix ex: <http://example.org/> .
ex:SimpleShape a sh:NodeShape, owl:Class ;
sh:property [
sh:path ex:hasPoint ;
sh:qualifiedValueShape [ sh:class brick:Sensor ] ;
sh:qualifiedMinCount 1 ;
] .
"""
# create a ShapeCollection to hold the shapes
my_shapes = ShapeCollection.create()
my_shapes.graph.parse(data=my_shapes_source, format="ttl")
# create a Library to hold the generated templates
lib = Library.create("my-library")
my_shapes.infer_templates(lib)
print(lib.get_templates())
```

### Example

Consider the following shape which has been loaded into BuildingMOTIF as part of a Library:
Expand Down

0 comments on commit 393d290

Please sign in to comment.