diff --git a/buildingmotif/building_motif/building_motif.py b/buildingmotif/building_motif/building_motif.py index d8805d93f..a01691a0d 100644 --- a/buildingmotif/building_motif/building_motif.py +++ b/buildingmotif/building_motif/building_motif.py @@ -1,6 +1,7 @@ import logging import os from contextlib import contextmanager +from typing import Optional from rdflib import Graph from rdflib.namespace import NamespaceManager @@ -24,16 +25,26 @@ class BuildingMOTIF(metaclass=Singleton): """Manages BuildingMOTIF data classes.""" - def __init__(self, db_uri: str, log_level=logging.WARNING) -> None: + def __init__( + self, + db_uri: str, + shacl_engine: Optional[str] = "pyshacl", + log_level=logging.WARNING, + ) -> None: """Class constructor. :param db_uri: database URI :type db_uri: str + :param shacl_engine: the name of the engine to use for validation: "pyshacl" or "topquadrant". Using topquadrant + requires Java to be installed on this machine, and the "topquadrant" feature on BuildingMOTIF, + defaults to "pyshacl" + :type shacl_engine: str, optional :param log_level: logging level of detail :type log_level: int :default log_level: INFO """ self.db_uri = db_uri + self.shacl_engine = shacl_engine self.engine = create_engine( db_uri, echo=False, diff --git a/buildingmotif/dataclasses/model.py b/buildingmotif/dataclasses/model.py index acb154e8b..cae4319ac 100644 --- a/buildingmotif/dataclasses/model.py +++ b/buildingmotif/dataclasses/model.py @@ -197,7 +197,9 @@ def validate( data_graph = copy_graph(self.graph) # validate the data graph - valid, report_g, report_str = shacl_validate(data_graph, shapeg, engine=engine) + valid, report_g, report_str = shacl_validate( + data_graph, shapeg, engine=self._bm.shacl_engine + ) return ValidationContext( shape_collections, shapeg, @@ -231,7 +233,9 @@ def compile( model_graph = copy_graph(self.graph).skolemize() - return shacl_inference(model_graph, ontology_graph, engine) + return shacl_inference( + model_graph, ontology_graph, engine=self._bm.shacl_engine + ) def test_model_against_shapes( self, diff --git a/buildingmotif/progressive_creation.py b/buildingmotif/progressive_creation.py new file mode 100644 index 000000000..c4589a014 --- /dev/null +++ b/buildingmotif/progressive_creation.py @@ -0,0 +1,137 @@ +from collections import Counter +from secrets import token_hex +from typing import Callable, Dict, List + +from rdflib import Graph +from rdflib.term import Node + +from buildingmotif.dataclasses import Library, Template +from buildingmotif.namespaces import PARAM +from buildingmotif.template_matcher import ( + _ontology_lookup_cache, + get_semantic_feasibility, +) +from buildingmotif.utils import Triple + +# from rdflib.compare import graph_diff + + +def _is_parameterized_triple(triple: Triple) -> bool: + """ + Returns true if a parameter appears in the triple + """ + st = (str(triple[0]), str(triple[1]), str(triple[2])) + return st[0].startswith(PARAM) or st[1].startswith(PARAM) or st[2].startswith(PARAM) + + +def _template_from_triples(lib: Library, triples: List[Triple]) -> Template: + g = Graph() + for t in triples: + g.add(t) + return lib.create_template(token_hex(4), g) + + +def compatible_triples( + a: Triple, b: Triple, sf_func: Callable[[Node, Node], bool] +) -> bool: + """ + Two triples are compatible if: + - they are the same + - they are pairwise semantically feasible + """ + if a == b: + return True + for (a_term, b_term) in zip(a, b): + if not sf_func(a_term, b_term): + return False + return True + + +def progressive_plan(templates: List[Template], context: Graph) -> List[Template]: + """ + We are given a list of templates; this is either a set of templates that + the model author supplies directly or are derived from sets of shapes that + need to be fulfilled. + + We want to optimize the population of these templates: there may be + redundant information between them, or there may be some unique/rare parts + of templates whose populations can be postponed. + """ + # present result as a sequence of (parameterized) triples? + + # greedy algorithm: + # start with the most common (substitutable) triple amongst all templates + # then choose the next most common triple, and so on. + # Yield triples that have parameters; automatically include + # non-parameterized triples + templates = [template.inline_dependencies() for template in templates] + histogram: Counter = Counter() + + inv: Dict[Triple, Template] = {} + + for templ in templates: + cache = _ontology_lookup_cache() + for body_triple in templ.body.triples((None, None, None)): + found = False + for hist_triple in histogram.keys(): + sf_func = get_semantic_feasibility( + templ.body, inv[hist_triple].body, context, cache + ) + if compatible_triples(body_triple, hist_triple, sf_func): + print(body_triple) + print(hist_triple) + print("-" * 50) + found = True + histogram[hist_triple] += 1 + break + if not found: + histogram[body_triple] = 1 + inv[body_triple] = templ + + # Start with the most common triple. We want to generate a sequence of triples + # that maximizes the number of templates that are included in the resulting graph. + # This is analogous to creating a left-biased CDF of (original) templates included + + # idea 1: just iterate through most common histogram + # This has no guarantee that the sequence is optimal *or* connected. + # We probably want to prioritize creating a connected sequence.. + most_common = histogram.most_common() + triples = [triple[0] for triple in most_common] + + template_sequence: List[Template] = [] + + lib = Library.create("temporary") + buffer: List[Triple] = [] + for triple in triples: + buffer.append(triple) + if _is_parameterized_triple(triple): + template_sequence.append(_template_from_triples(lib, buffer)) + buffer.clear() + if len(buffer) > 0: + template_sequence.append(_template_from_triples(lib, buffer)) + + # TODO: need to mark when a new template is satisfied + + # can look at the CDF for a site specification as part of paper evaluation + + # stub of an alternative approach... + # for pair in combinations(templates, 2): + # for pair in permutations(templates, 2): + # t1, t2 = pair[0].in_memory_copy(), pair[1].in_memory_copy() + # print(t1.body.serialize()) + # print("-" * 100) + # print(t2.body.serialize()) + # print("-" * 100) + # tm = TemplateMatcher(t1.body, t2, context) + # for mapping, subgraph in tm.building_mapping_subgraphs_iter( + # size=tm.largest_mapping_size + # ): + # pprint(mapping) + # print(subgraph.serialize()) + + # # both, first, second = graph_diff(pair[0].body, pair[1].body) + # # print(both.serialize()) + # print("*" * 100) + # print("*" * 100) + + return template_sequence diff --git a/buildingmotif/utils.py b/buildingmotif/utils.py index 1bba5cd25..46fa4f161 100644 --- a/buildingmotif/utils.py +++ b/buildingmotif/utils.py @@ -588,7 +588,9 @@ def shacl_validate( def shacl_inference( - data_graph: Graph, shape_graph: Optional[Graph] = None, engine="topquadrant" + data_graph: Graph, + shape_graph: Optional[Graph] = None, + engine: Optional[str] = "topquadrant", ) -> Graph: """ Infer new triples in the data graph using the shape graph. diff --git a/tests/unit/api/test_model.py b/tests/unit/api/test_model.py index 422431092..e165f88b6 100644 --- a/tests/unit/api/test_model.py +++ b/tests/unit/api/test_model.py @@ -254,6 +254,7 @@ def test_create_model_bad_name(client, building_motif): def test_validate_model(client, building_motif, shacl_engine): + building_motif.shacl_engine = shacl_engine # Set up brick = Library.load(ontology_graph="tests/unit/fixtures/Brick.ttl") assert brick is not None @@ -312,7 +313,8 @@ def test_validate_model(client, building_motif, shacl_engine): assert results.get_json()["reasons"] == {} -def test_validate_model_bad_model_id(client, building_motif): +def test_validate_model_bad_model_id(client, building_motif, shacl_engine): + building_motif.shacl_engine = shacl_engine # Set up library = Library.load(ontology_graph="tests/unit/fixtures/shapes/shape1.ttl") assert library is not None @@ -328,7 +330,8 @@ def test_validate_model_bad_model_id(client, building_motif): assert results.status_code == 404 -def test_validate_model_no_args(client, building_motif): +def test_validate_model_no_args(client, building_motif, shacl_engine): + building_motif.shacl_engine = shacl_engine # Set up BLDG = Namespace("urn:building/") model = Model.create(name=BLDG) @@ -347,7 +350,8 @@ def test_validate_model_no_args(client, building_motif): assert results.get_json()["reasons"] == {} -def test_validate_model_no_library_ids(client, building_motif): +def test_validate_model_no_library_ids(client, building_motif, shacl_engine): + building_motif.shacl_engine = shacl_engine # Set up BLDG = Namespace("urn:building/") model = Model.create(name=BLDG) @@ -416,7 +420,8 @@ def test_validate_model_bad_args(client, building_motif): assert results.status_code == 400 -def test_test_model_against_shapes(client, building_motif): +def test_test_model_against_shapes(client, building_motif, shacl_engine): + building_motif.shacl_engine = shacl_engine # Load libraries Library.load(ontology_graph=str(PROJECT_DIR / "libraries/brick/Brick-subset.ttl")) ashrae_g36 = Library.load( diff --git a/tests/unit/dataclasses/test_model.py b/tests/unit/dataclasses/test_model.py index 7905c0062..fdca7611b 100644 --- a/tests/unit/dataclasses/test_model.py +++ b/tests/unit/dataclasses/test_model.py @@ -54,6 +54,7 @@ def test_update_model_manifest(clean_building_motif): def test_validate_model_manifest(clean_building_motif, shacl_engine): + clean_building_motif.shacl_engine = shacl_engine m = Model.create(name="https://example.com", description="a very good model") m.graph.add((URIRef("https://example.com/vav1"), A, BRICK.VAV)) @@ -91,6 +92,7 @@ def test_validate_model_manifest(clean_building_motif, shacl_engine): def test_validate_model_manifest_with_imports(clean_building_motif, shacl_engine): + clean_building_motif.shacl_engine = shacl_engine m = Model.create(name="https://example.com", description="a very good model") m.graph.add((URIRef("https://example.com/vav1"), A, BRICK.VAV)) @@ -125,11 +127,12 @@ def test_validate_model_manifest_with_imports(clean_building_motif, shacl_engine ) # validate against manifest -- should pass now - result = m.validate(engine=shacl_engine) + result = m.validate() assert result.valid, result.report_string def test_validate_model_explicit_shapes(clean_building_motif, shacl_engine): + clean_building_motif.shacl_engine = shacl_engine # load library Library.load(ontology_graph="tests/unit/fixtures/Brick1.3rc1-equip-only.ttl") lib = Library.load(ontology_graph="tests/unit/fixtures/shapes/shape1.ttl") @@ -158,6 +161,7 @@ def test_validate_model_with_failure(bm: BuildingMOTIF, shacl_engine): """ Test that a model correctly validates """ + bm.shacl_engine = shacl_engine shape_graph_data = """ @prefix sh: . @prefix rdfs: . @@ -202,6 +206,7 @@ def test_validate_model_with_failure(bm: BuildingMOTIF, shacl_engine): def test_model_compile(bm: BuildingMOTIF, shacl_engine): """Test that model compilation gives expected results""" + bm.shacl_engine = shacl_engine small_office_model = Model.create("http://example.org/building/") small_office_model.graph.parse( "tests/unit/fixtures/smallOffice_brick.ttl", format="ttl" @@ -236,6 +241,7 @@ def test_get_manifest(clean_building_motif): def test_validate_with_manifest(clean_building_motif, shacl_engine): + clean_building_motif.shacl_engine = shacl_engine g = Graph() g.parse( data=""" @@ -272,12 +278,13 @@ def test_validate_with_manifest(clean_building_motif, shacl_engine): manifest = model.get_manifest() manifest.add_graph(manifest_g) - ctx = model.validate(None, engine=shacl_engine) + ctx = model.validate() assert not ctx.valid, "Model validated but it should throw an error" def test_get_validation_severity(clean_building_motif, shacl_engine): NS = Namespace("urn:ex/") + clean_building_motif.shacl_engine = shacl_engine g = Graph() g.parse( data=""" @@ -337,7 +344,7 @@ def test_get_validation_severity(clean_building_motif, shacl_engine): manifest = model.get_manifest() manifest.add_graph(manifest_g) - ctx = model.validate(None, engine=shacl_engine) + ctx = model.validate() assert not ctx.valid, "Model validated but it should throw an error" # check that only valid severity values are accepted diff --git a/tests/unit/fixtures/template-inline-test/1.yml b/tests/unit/fixtures/template-inline-test/1.yml new file mode 100644 index 000000000..d9d56e86b --- /dev/null +++ b/tests/unit/fixtures/template-inline-test/1.yml @@ -0,0 +1,18 @@ +parent: + body: > + @prefix P: . + @prefix brick: . + P:name a brick:Equipment ; + brick:hasPoint P:sensor ; + brick:hasPart P:dep . + dependencies: + - template: child + args: {"name": "dep", "child-opt-arg": "sensor"} + +child: + body: > + @prefix P: . + @prefix brick: . + P:name a brick:Equipment ; + brick:hasPoint P:child-opt-arg . + optional: ["child-opt-arg"] diff --git a/tests/unit/fixtures/templates-variadic/1.yml b/tests/unit/fixtures/templates-variadic/1.yml new file mode 100644 index 000000000..d23b18b55 --- /dev/null +++ b/tests/unit/fixtures/templates-variadic/1.yml @@ -0,0 +1,17 @@ +vav: + body: > + @prefix P: . + @prefix brick: . + P:name a brick:VAV ; + brick:hasPoint P:part\+ ; + brick:hasPoint P:point\* . + dependencies: + - template: temp-sensor + args: {"name": "point"} + +temp-sensor: + body: > + @prefix P: . + @prefix brick: . + P:name a brick:Temperature_Sensor ; + brick:hasUnit P:unit\? . diff --git a/tests/unit/test_generate_template_progression.py b/tests/unit/test_generate_template_progression.py new file mode 100644 index 000000000..21781c50e --- /dev/null +++ b/tests/unit/test_generate_template_progression.py @@ -0,0 +1,56 @@ +from typing import Dict + +from rdflib import Namespace +from rdflib.term import Node + +from buildingmotif import BuildingMOTIF +from buildingmotif.dataclasses import Library, Model, Template +from buildingmotif.progressive_creation import progressive_plan + + +def test_generate_valid_progression(bm: BuildingMOTIF): + BLDG = Namespace("urn:bldg#") + model = Model.create(BLDG) + brick = Library.load( + ontology_graph="tests/unit/fixtures/Brick1.3rc1-equip-only.ttl" + ) + templates = Library.load(directory="tests/unit/fixtures/progressive/templates") + tstat_templs = [ + templates.get_template_by_name("tstat"), + templates.get_template_by_name("tstat-location"), + ] + template_sequence = progressive_plan( + tstat_templs, brick.get_shape_collection().graph + ) + + bindings: Dict[str, Node] = {} + for templ in template_sequence: + templ = templ.evaluate(bindings) + if isinstance(templ, Template): + new_bindings, graph = templ.fill(BLDG) + bindings.update(new_bindings) + else: + graph = templ + model.add_graph(graph) + + print(model.graph.serialize()) + + # test that model contains what we expect + q1 = """ + PREFIX brick: + SELECT ?tstat ?temp ?sp WHERE { + ?tstat a brick:Thermostat ; + brick:hasPoint ?temp, ?sp . + ?temp a brick:Temperature_Sensor . + ?sp a brick:Temperature_Setpoint . + }""" + assert len(list(model.graph.query(q1))) == 1 + + q2 = """ + PREFIX brick: + SELECT ?tstat ?room WHERE { + ?tstat a brick:Thermostat ; + brick:hasLocation ?room . + ?room a brick:Room . + }""" + assert len(list(model.graph.query(q2))) == 1 diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py index 1cf173461..9109fd92e 100644 --- a/tests/unit/test_utils.py +++ b/tests/unit/test_utils.py @@ -161,7 +161,7 @@ def test_inline_sh_nodes(shacl_engine): """ ) # should pass - valid, _, report = shacl_validate(shape_g, engine=shacl_engine) + valid, _, report = shacl_validate(shape_g) assert valid, report shape1_cbd = shape_g.cbd(URIRef("urn:ex/shape1")) @@ -169,7 +169,7 @@ def test_inline_sh_nodes(shacl_engine): shape_g = rewrite_shape_graph(shape_g) # should pass - valid, _, report = shacl_validate(shape_g, engine=shacl_engine) + valid, _, report = shacl_validate(shape_g) assert valid, report shape1_cbd = shape_g.cbd(URIRef("urn:ex/shape1")) @@ -177,6 +177,7 @@ def test_inline_sh_nodes(shacl_engine): def test_inline_sh_and(bm: BuildingMOTIF, shacl_engine): + bm.shacl_engine = shacl_engine sg = Graph() sg.parse( data=PREAMBLE @@ -248,6 +249,7 @@ def test_inline_sh_and(bm: BuildingMOTIF, shacl_engine): def test_inline_sh_node(bm: BuildingMOTIF, shacl_engine): + bm.shacl_engine = shacl_engine sg = Graph() sg.parse( data=PREAMBLE