diff --git a/buildingmotif/dataclasses/validation.py b/buildingmotif/dataclasses/validation.py index d70fe9035..81832e525 100644 --- a/buildingmotif/dataclasses/validation.py +++ b/buildingmotif/dataclasses/validation.py @@ -1,13 +1,14 @@ +import re from collections import defaultdict from dataclasses import dataclass, field from functools import cached_property from itertools import chain from secrets import token_hex -from typing import TYPE_CHECKING, Dict, List, Optional, Set, Tuple, Union +from typing import TYPE_CHECKING, Dict, Generator, List, Optional, Set, Tuple, Union import rdflib from rdflib import Graph, URIRef -from rdflib.term import Node +from rdflib.term import BNode, Node from buildingmotif import get_building_motif from buildingmotif.dataclasses.shape_collection import ShapeCollection @@ -94,6 +95,51 @@ class PathClassCount(GraphDiff): maxc: Optional[int] classname: URIRef + @classmethod + def from_validation_report(cls, report: Graph) -> List["PathClassCount"]: + """Construct PathClassCount objects from a SHACL validation report. + + :param report: the SHACL validation report + :type report: Graph + :return: a list of PathClassCount objects + :rtype: List[PathClassCount] + """ + + query = """ + PREFIX sh: + SELECT ?focus ?path ?minc ?maxc ?classname WHERE { + ?result sh:sourceShape/sh:qualifiedValueShape? ?shape . + { ?result sh:sourceConstraintComponent sh:CountConstraintComponent } + UNION + { ?result sh:sourceConstraintComponent sh:QualifiedMinCountConstraintComponent } + ?result sh:focusNode ?focus . + ?shape sh:resultPath ?path . + { + ?shape sh:class ?classname . + ?shape sh:minCount ?minc . + OPTIONAL { ?shape sh:maxCount ?maxc } + } + UNION + { + ?shape sh:qualifiedValueShape [ sh:class ?classname ] . + ?shape sh:qualifiedMinCount ?minc . + OPTIONAL { ?shape sh:qualifiedMaxCount ?maxc } + } + }""" + results = report.query(query) + return [ + cls( + focus, + report, + report, + path, + minc, + maxc, + classname, + ) + for focus, path, minc, maxc, classname in results + ] + def reason(self) -> str: """Human-readable explanation of this GraphDiff.""" return f"{self.focus} needs between {self.minc} and {self.maxc} instances of \ @@ -109,11 +155,14 @@ def resolve(self, lib: "Library") -> List["Template"]: """ assert self.focus is not None body = Graph() + # extract everything after the last "delimiter" character from self.classname + name = re.split(r"[#\/]", self.classname)[-1] + focus = re.split(r"[#\/]", self.focus)[-1] for _ in range(self.minc or 0): inst = _gensym() body.add((self.focus, self.path, inst)) body.add((inst, A, self.classname)) - return [lib.create_template(f"resolve_{token_hex(4)}", body)] + return [lib.create_template(f"resolve_{focus}{name}", body)] @dataclass(frozen=True, unsafe_hash=True) @@ -130,6 +179,54 @@ class PathShapeCount(GraphDiff): extra_body: Optional[Graph] = field(hash=False) extra_deps: Optional[Tuple] = field(hash=False) + @classmethod + def from_validation_report( + cls, report: Graph + ) -> Generator["PathShapeCount", None, None]: + """Construct PathShapeCount objects from a SHACL validation report. + + :param report: the SHACL validation report + :type report: Graph + :return: a list of PathShapeCount objects + :rtype: List[PathShapeCount] + """ + query = """ + PREFIX sh: + SELECT ?focus ?path ?minc ?maxc ?shapename WHERE { + ?result sh:sourceShape ?shape . + ?result sh:resultPath ?path . + { ?result sh:sourceConstraintComponent sh:CountConstraintComponent } + UNION + { ?result sh:sourceConstraintComponent sh:QualifiedMinCountConstraintComponent } + ?result sh:focusNode ?focus . + { + ?shape sh:node ?shapename . + ?shape sh:minCount ?minc . + OPTIONAL { ?shape sh:maxCount ?maxc } + } + UNION + { + ?shape sh:qualifiedValueShape [ sh:node ?shapename ] . + ?shape sh:qualifiedMinCount ?minc . + OPTIONAL { ?shape sh:qualifiedMaxCount ?maxc } + } + + }""" + results = report.query(query) + for (focus, path, minc, maxc, shapename) in results: + extra_body, deps = get_template_parts_from_shape(shapename, report) + yield cls( + focus, + report, + report, + path, + minc, + maxc, + shapename, + extra_body, + tuple(deps), + ) + def reason(self) -> str: """Human-readable explanation of this GraphDiff.""" return f"{self.focus} needs between {self.minc} and {self.maxc} instances of \ @@ -142,6 +239,9 @@ def resolve(self, lib: "Library") -> List["Template"]: if self.extra_deps: for dep in self.extra_deps: dep["args"] = {k: str(v)[len(PARAM) :] for k, v in dep["args"].items()} + # extract everything after the last "delimiter" character from self.shapename + name = re.split(r"[#\/]", self.shapename)[-1] + focus = re.split(r"[#\/]", self.focus)[-1] for _ in range(self.minc or 0): body = Graph() inst = PARAM["name"] @@ -150,7 +250,7 @@ def resolve(self, lib: "Library") -> List["Template"]: if self.extra_body: replace_nodes(self.extra_body, {PARAM.name: inst}) body += self.extra_body - templ = lib.create_template(f"resolve{token_hex(4)}", body) + templ = lib.create_template(f"resolve{focus}{name}", body) if self.extra_deps: from buildingmotif.dataclasses.template import Template @@ -171,6 +271,45 @@ class RequiredPath(GraphDiff): minc: Optional[int] maxc: Optional[int] + @classmethod + def from_validation_report(cls, report: Graph) -> List["RequiredPath"]: + """Construct RequiredPath objects from a SHACL validation report. + + :param report: the SHACL validation report + :type report: Graph + :return: a list of RequiredPath objects + :rtype: List[RequiredPath] + """ + query = """ + PREFIX sh: + SELECT ?focus ?path ?minc ?maxc WHERE { + ?result sh:sourceShape ?shape . + ?result sh:resultPath ?path . + { ?result sh:sourceConstraintComponent sh:CountConstraintComponent } + UNION + { ?result sh:sourceConstraintComponent sh:QualifiedMinCountConstraintComponent } + ?result sh:focusNode ?focus . + { + ?shape sh:minCount ?minc . + OPTIONAL { ?shape sh:maxCount ?maxc } + } UNION { + ?shape sh:qualifiedMinCount ?minc . + OPTIONAL { ?shape sh:qualifiedMaxCount ?maxc } + } + }""" + results = report.query(query) + return [ + cls( + focus, + report, + report, + path, + minc, + maxc, + ) + for focus, path, minc, maxc in results + ] + def reason(self) -> str: """Human-readable explanation of this GraphDiff.""" return f"{self.focus} needs between {self.minc} and {self.maxc} uses \ @@ -186,10 +325,13 @@ def resolve(self, lib: "Library") -> List["Template"]: """ assert self.focus is not None body = Graph() + # extract everything after the last "delimiter" character from self.shapename + name = re.split(r"[#\/]", self.path)[-1] + focus = re.split(r"[#\/]", self.focus)[-1] for _ in range(self.minc or 0): inst = _gensym() body.add((self.focus, self.path, inst)) - return [lib.create_template(f"resolve{token_hex(4)}", body)] + return [lib.create_template(f"resolve{focus}{name}", body)] @dataclass(frozen=True) @@ -212,8 +354,9 @@ def resolve(self, lib: "Library") -> List["Template"]: """ assert self.focus is not None body = Graph() + name = re.split(r"[#\/]", self.classname)[-1] body.add((self.focus, A, self.classname)) - return [lib.create_template(f"resolve{token_hex(4)}", body)] + return [lib.create_template(f"resolveSelf{name}", body)] @dataclass(frozen=True) @@ -238,10 +381,11 @@ def resolve(self, lib: "Library") -> List["Template"]: :rtype: List[Template] """ templs = [] + name = re.split(r"[#\/]", self.classname)[-1] for _ in range(self.expectedCount): template_body = Graph() template_body.add((PARAM["name"], A, self.classname)) - templs.append(lib.create_template(f"resolve{token_hex(4)}", template_body)) + templs.append(lib.create_template(f"resolveAdd{name}", template_body)) return templs @@ -276,6 +420,24 @@ def as_templates(self) -> List["Template"]: """ return diffset_to_templates(self.diffset) + def get_broken_entities(self) -> Set[URIRef]: + """Get the set of entities that are broken in the model. + + :return: set of entities that are broken + :rtype: Set[URIRef] + """ + return {diff or "Model" for diff in self.diffset} + + def get_diffs_for_entity(self, entity: URIRef) -> Set[GraphDiff]: + """Get the set of diffs for a specific entity. + + :param entity: the entity to get diffs for + :type entity: URIRef + :return: set of diffs for the entity + :rtype: Set[GraphDiff] + """ + return self.diffset.get(entity, set()) + def get_reasons_with_severity( self, severity: Union[URIRef, str] ) -> Dict[Optional[URIRef], Set[GraphDiff]]: @@ -326,6 +488,7 @@ def _report_to_diffset(self) -> Dict[Optional[URIRef], Set[GraphDiff]]: g = self.report + self.shapes_graph diffs: Dict[Optional[URIRef], Set[GraphDiff]] = defaultdict(set) + for result in g.objects(predicate=SH.result): # check if the failure is due to our count constraint component focus = g.value(result, SH.focusNode) @@ -355,6 +518,8 @@ def _report_to_diffset(self) -> Dict[Optional[URIRef], Set[GraphDiff]]: ): requiring_shape = g.value(result, SH.sourceShape) expected_class = g.value(requiring_shape, SH["class"]) + if expected_class is None or isinstance(expected_class, BNode): + continue diffs[focus].add( RequiredClass(focus, validation_report, g, expected_class) ) @@ -454,7 +619,7 @@ def diffset_to_templates( continue templ_lists = (diff.resolve(lib) for diff in diffset) - templs = list(filter(None, chain.from_iterable(templ_lists))) + templs: List[Template] = list(filter(None, chain.from_iterable(templ_lists))) if len(templs) <= 1: templates.extend(templs) continue diff --git a/docs/guides/csv-import.md b/docs/guides/csv-import.md index 4c3bc0919..1f0fb2eb2 100644 --- a/docs/guides/csv-import.md +++ b/docs/guides/csv-import.md @@ -2,7 +2,7 @@ Assume we have the following Template in a library called `csv-tutorial`: -```yml +```yaml my-thermostat: body: > @prefix P: . diff --git a/docs/tutorials/model_correction.md b/docs/tutorials/model_correction.md index ecdaceb89..574b8a352 100644 --- a/docs/tutorials/model_correction.md +++ b/docs/tutorials/model_correction.md @@ -26,14 +26,14 @@ Like the previous tutorial, we'll create an in-memory BuildingMOTIF instance, lo ```{margin} ```{warning} -Currently, libraries in `../../buildingmotif/libraries/` are *included* and libraries in `../../libraries/` are *excluded* from the [BuildingMOTIF Python package](https://pypi.org/project/buildingmotif/) (available by cloning, downloading, or forking the repository). See https://github.com/NREL/BuildingMOTIF/issues/133. +Currently, libraries in `../../buildingmotif/libraries/` are *included* and libraries in `../../libraries/` are *excluded* from the [BuildingMOTIF Python package](https://pypi.org/project/buildingmotif/) (available by cloning, downloading, or forking the repository). See https://github.com/NREL/BuildingMOTIF/issues/133. ``` ```{code-cell} -from rdflib import Namespace +from rdflib import Namespace, URIRef from buildingmotif import BuildingMOTIF -from buildingmotif.dataclasses import Model, Library -from buildingmotif.namespaces import BRICK # import this to make writing URIs easier +from buildingmotif.dataclasses import Model, Library, Template +from buildingmotif.namespaces import BRICK, RDF # import this to make writing URIs easier # in-memory instance bm = BuildingMOTIF("sqlite://") @@ -44,33 +44,28 @@ BLDG = Namespace('urn:bldg/') # create the building model model = Model.create(BLDG, description="This is a test model for a simple building") -# load tutorial 2 model and manifest -model.graph.parse("tutorial2_model.ttl", format="ttl") -manifest = Library.load(ontology_graph="tutorial2_manifest.ttl") - # load libraries included with the python package constraints = Library.load(ontology_graph="constraints/constraints.ttl") # load libraries excluded from the python package (available from the repository) brick = Library.load(ontology_graph="../../libraries/brick/Brick-subset.ttl") g36 = Library.load(directory="../../libraries/ashrae/guideline36") + + +# load tutorial 2 model and manifest +model.graph.parse("tutorial2_model.ttl", format="ttl") +manifest = Library.load(ontology_graph="tutorial2_manifest.ttl") + +# assign the manifest to our model +model.update_manifest(manifest.get_shape_collection()) ``` ## Model Validation -Let's validate the model again to see what's causing the failure. +Let's validate the model again to see what's causing the failure. ```{code-cell} -# gather shape collections into a list for ease of use -shape_collections = [ - brick.get_shape_collection(), - constraints.get_shape_collection(), - manifest.get_shape_collection(), - g36.get_shape_collection(), -] - -# pass a list of shape collections to .validate() -validation_result = model.validate(shape_collections) +validation_result = model.validate() print(f"Model is valid? {validation_result.valid}") # print reasons @@ -79,6 +74,21 @@ for uri, diffset in validation_result.diffset.items(): print(f" - {diff.reason()}") ``` +We can get this information in a few different ways, too. +For example, asking for all the entities which have failed validation: + +```{code-cell} +for e in validation_result.get_broken_entities(): + print(e) +``` + +We can also get all reasons a particular entity has failed validation: + +```{code-cell} +for diff in validation_result.get_diffs_for_entity(BLDG["Core_ZN-PSC_AC"]): + print(diff.reason()) +``` + ## Model Correction with Templates The model is failing because the AHU doesn't have the minimum number of supply fans associated with it. We *could* add the fan explicitly by adding those triples to the model like we've done previously, but we can also ask BuildingMOTIF to generate new templates that explicitly prompt us for the missing information. We can also take a closer look at the first autogenerated template @@ -86,9 +96,10 @@ The model is failing because the AHU doesn't have the minimum number of supply f ```{code-cell} # create a new library to hold these generated templates generated_templates = Library.create("my-autogenerated-templates") -for uri, diffset in validation_result.diffset.items(): - for diff in diffset: - diff.resolve(generated_templates) + +# loop through all results for the AHU +for diff in validation_result.get_diffs_for_entity(BLDG["Core_ZN-PSC_AC"]): + diff.resolve(generated_templates) # print some of the autogenerated template for templ in generated_templates.get_templates(): @@ -107,44 +118,103 @@ If we just add the generated templates to the building model, we will probably p [^1]: https://bacnet.org/ ```{code-cell} -from buildingmotif.namespaces import BRICK +# use the name of the AHU from above as the base of our template names +ahu_name = "Core_ZN-PSC_AC" + +# lookup for the name of the template to the name of the point or part +points_and_parts = { + "resolve_Core_ZN-PSC_ACMixed_Air_Temperature_Sensor": "-MAT", + "resolve_Core_ZN-PSC_ACFilter_Differential_Pressure_Sensor": "-FilterDPS", + "resolve_Core_ZN-PSC_ACCooling_Command": "-CCmd", + "resolve_Core_ZN-PSC_ACHeating_Command": "-HCmd", + "resolve_Core_ZN-PSC_ACOutside_Air_Temperature_Sensor": "-OAT", + "resolve_Core_ZN-PSC_ACSupply_Air_Temperature_Sensor": "-SAT", + "resolve_Core_ZN-PSC_ACReturn_Air_Temperature_Sensor": "-RAT", + "resolveCore_ZN-PSC_ACsa-fan": "-Fan", # this is an existing fan in the model! +} + for templ in generated_templates.get_templates(): templ = templ.inline_dependencies() - # get name of AHU from template body; this is the subject of the "brick:hasPart P:name" triple - bindings = templ.body.query("SELECT ?ahu WHERE { ?ahu brick:hasPart }").bindings - if not len(bindings): - # skip this template if there is no 'ahu' variable in it - continue - # bindings[0]['ahu'] returns the value of the 'ahu' variable for the first row of the results - ahu_name = bindings[0]['ahu'] - - # Generate the name of the supply fan. If we were modeling a real building, - # we would want to use the name of the actual supply fan. Here, we just choose - # a sensible name for the purposes of the tutorial. - supply_fan_name = ahu_name + '-Fan' + suffix = points_and_parts[templ.name] # we know from the exploration above that each template has - # 1 parameter called 'name', which we assign to the name of the supply fan + # 1 parameter which is the name of the missing item + param = list(templ.parameters)[0] bindings = { - "name": supply_fan_name, + param: BLDG[ahu_name + suffix], } - # the rest of the parameters have random names, so let's generate - # random URIs for those by putting the name of the parameter at the end - # of the fan's name - for p in templ.parameters - {'name'}: - print(p) - bindings[p] = supply_fan_name + p - supply_fan = templ.evaluate(bindings) - model.add_graph(supply_fan) - print(f"Added supply fan {supply_fan_name}") + thing = templ.evaluate(bindings) + if isinstance(thing, Template): + # there might be other parameters on a template. Invent names for them + _, thing = thing.fill(BLDG) + model.add_graph(thing) ``` We use the same code as before to ask BuildingMOTIF if the model is now valid: ```{code-cell} -validation_result = model.validate(shape_collections) +validation_result = model.validate() +print(f"Model is valid? {validation_result.valid}") +# print reasons +for uri, diffset in validation_result.diffset.items(): + for diff in diffset: + print(f" - {diff.reason()}") +``` + +We are still not finished. The `sa-fan` shape has its own requirements for necessary points. +Let's use the same process above to get templates we can fill out to repair the model + +```{code-cell} +generated_templates_sf = Library.create("my-autogenerated-templates-sf") +for diff in validation_result.get_diffs_for_entity(BLDG["Core_ZN-PSC_AC-Fan"]): + diff.resolve(generated_templates_sf) + +# print some of the autogenerated template +for templ in generated_templates_sf.get_templates(): + templ = templ.inline_dependencies() + print(f"Name (autogenerated): {templ.name}") + print(f"Parameters (autogenerated): {templ.parameters}") + print("Template body (autogenerated):") + print(templ.body.serialize()) + print('-' * 79) +``` + +Use the names of these templates to build a lookup table for the point and part names. + +```{code-cell} +sf_name = "Core_ZN-PSC_AC-Fan" + +# lookup for the name of the template to the name of the point or part +points_and_parts = { + "resolve_Core_ZN-PSC_AC-FanFrequency_Command": "-Freq", + "resolve_Core_ZN-PSC_AC-FanStart_Stop_Command": "-StartStop", + "resolve_Core_ZN-PSC_AC-FanFan_Status": "-Sts", +} +for templ in generated_templates_sf.get_templates(): + templ = templ.inline_dependencies() + + suffix = points_and_parts[templ.name] + + param = list(templ.parameters)[0] + bindings = { + param: BLDG[sf_name + suffix], + } + thing = templ.evaluate(bindings) + model.add_graph(thing) +``` + +We can re-check the validation of the model now: + +```{code-cell} +validation_result = model.validate() print(f"Model is valid? {validation_result.valid}") +print(validation_result.report.serialize()) + +# print reasons +for uri, diffset in validation_result.diffset.items(): + for diff in diffset: + print(f" - {diff.reason()}") ``` Success! The model is valid with respect to the targeted use case, i.e. the model can support the high-performance sequences of operation for single zone VAV AHUs from ASHRAE Guideline 36. Let's take a look at the validated model and save it for use in future tutorials. diff --git a/docs/tutorials/model_creation.md b/docs/tutorials/model_creation.md index cab8c707a..e1da0bd51 100644 --- a/docs/tutorials/model_creation.md +++ b/docs/tutorials/model_creation.md @@ -48,7 +48,7 @@ A `Model` is an RDF graph representing part or all of a building. Now that we have a BuildingMOTIF instance, we can create a `Model`. Creating a model requires importing the `Model` class, creating an RDF namespace to hold all of the entities in the model, and telling BuildingMOTIF to create a new model with that namespace. The namespace is a URL used to uniquely identify the model. ```{code-cell} -from rdflib import Namespace +from rdflib import Namespace, RDF from buildingmotif.dataclasses import Model # create the namespace @@ -85,7 +85,7 @@ Currently, libraries in `../../buildingmotif/libraries/` are *included* and libr ```{code-cell} # load a library from buildingmotif.dataclasses import Library -brick = Library.load(ontology_graph="../../libraries/brick/Brick-subset.ttl") +brick = Library.load(ontology_graph="../../libraries/brick/Brick-full.ttl") # print the first 10 templates print("The Brick library contains the following templates:") @@ -108,7 +108,7 @@ To add an RDF triple to a model, use the `Model.graph.add()` method: # import this to make writing URIs easier from buildingmotif.namespaces import BRICK, RDF -model.graph.add((BLDG["abc"], RDF.type, BRICK.Temperature_Sensor)) +model.graph.add((BLDG["zone-temp"], RDF.type, BRICK.Zone_Air_Temperature_Sensor)) ``` ### Importing RDF Graphs Into a Model @@ -131,7 +131,7 @@ my_graph = rdflib.Graph() my_graph.parse(data=''' @prefix bldg: . @prefix brick: . -bldg:A1 a brick:Air_Handler_Unit . +bldg:Core_ZN-PSC_AC a brick:Air_Handler_Unit . ''') model.add_graph(my_graph) ``` @@ -142,7 +142,7 @@ You can also use the `Model.graph.parse` method directly: model.graph.parse(data=''' @prefix bldg: . @prefix brick: . -bldg:A1 a brick:Air_Handler_Unit . +bldg:Core_ZN-PSC_AC a brick:Air_Handler_Unit . ''') ``` @@ -221,7 +221,8 @@ Next, we'll add some of the AHU's components (a fan, a damper, and a cooling coi ```{code-cell} # templates -oa_ra_damper_template = brick.get_template_by_name(BRICK.Damper) +oa_ra_damper_template = brick.get_template_by_name(BRICK.Outside_Damper) +damper_template = brick.get_template_by_name(BRICK.Damper) fan_template = brick.get_template_by_name(BRICK.Supply_Fan) clg_coil_template = brick.get_template_by_name(BRICK.Cooling_Coil) @@ -232,22 +233,34 @@ fan_graph = fan_template.evaluate(fan_binding_dict) model.add_graph(fan_graph) # add outdoor air/return air damper -oa_ra_damper_name = f"{ahu_name}-Damper" +oa_ra_damper_name = f"{ahu_name}-OutsideDamper" oa_ra_damper_binding_dict = {"name": BLDG[oa_ra_damper_name]} oa_ra_damper_graph = oa_ra_damper_template.evaluate(oa_ra_damper_binding_dict) model.add_graph(oa_ra_damper_graph) +# add other damper +damper_name = f"{ahu_name}-Damper" +damper_binding_dict = {"name": BLDG[damper_name]} +damper_graph = damper_template.evaluate(damper_binding_dict) +model.add_graph(damper_graph) + # add clg coil clg_coil_name = f"{ahu_name}-Clg_Coil" clg_coil_binding_dict = {"name": BLDG[clg_coil_name]} clg_coil_graph = clg_coil_template.evaluate(clg_coil_binding_dict) model.add_graph(clg_coil_graph) -# connect fan, damper, and clg coil to AHU +# connect zone-temp, fan, dampers, and clg coil to AHU +model.graph.add((BLDG[ahu_name], BRICK.hasPoint, BLDG["zone-temp"])) model.graph.add((BLDG[ahu_name], BRICK.hasPart, BLDG[oa_ra_damper_name])) +model.graph.add((BLDG[ahu_name], BRICK.hasPart, BLDG[damper_name])) model.graph.add((BLDG[ahu_name], BRICK.hasPart, BLDG[fan_name])) model.graph.add((BLDG[ahu_name], BRICK.hasPart, BLDG[clg_coil_name])) +# you can add triples directly too +model.graph.add((BLDG[oa_ra_damper_name], BRICK.hasPoint, BLDG[oa_ra_damper_name + "Position"])) +model.graph.add((BLDG[oa_ra_damper_name + "Position"], RDF.type, BRICK.Damper_Position_Command)) + # print model to confirm components were added and connected print(model.graph.serialize()) ``` diff --git a/docs/tutorials/model_validation.md b/docs/tutorials/model_validation.md index fbc13e159..e421bc64b 100644 --- a/docs/tutorials/model_validation.md +++ b/docs/tutorials/model_validation.md @@ -169,12 +169,12 @@ As an exercise, try writing shapes that require the model to contain the followi sh:node . ``` --> -Put all of the above in a new file called `tutorial2_manifest.ttl`. We'll also add a shape called `sz-vav-ahu-control-sequences`, which is a use case shape to validate the model against in the next section. +Put all of the above in a new file called `tutorial1_manifest.ttl`. We'll also add a shape called `sz-vav-ahu-control-sequences`, which is a use case shape to validate the model against in the next section. -The following block of code puts all of the above in the `tutorial2_manifest.ttl` file for you: +The following block of code puts all of the above in the `tutorial1_manifest.ttl` file for you: ```{code-cell} -with open("tutorial2_manifest.ttl", "w") as f: +with open("tutorial1_manifest.ttl", "w") as f: f.write(""" @prefix brick: . @prefix owl: . @@ -217,10 +217,6 @@ with open("tutorial2_manifest.ttl", "w") as f: constraint:exactCount 1 ; constraint:class brick:Heating_Coil . -:sz-vav-ahu-control-sequences a sh:NodeShape ; - sh:message "AHUs must match the single-zone VAV AHU shape" ; - sh:targetClass brick:AHU ; - sh:node . """) ``` @@ -233,9 +229,9 @@ the most common use case, so this is treated specially in BuildingMOTIF. ```{code-cell} # load manifest into BuildingMOTIF as its own library! -manifest = Library.load(ontology_graph="tutorial2_manifest.ttl") +manifest = Library.load(ontology_graph="tutorial1_manifest.ttl") # set it as the manifest for the model -model.update_manifest(manifest) +model.update_manifest(manifest.get_shape_collection()) ``` ### Validating the Model @@ -251,8 +247,10 @@ validation_result = model.validate() print(f"Model is valid? {validation_result.valid}") # print reasons -for diff in validation_result.diffset: - print(f" - {diff.reason()}") +for entity, errors in validation_result.diffset.items(): + print(entity) + for err in errors: + print(f" - {err.reason()}") ``` ```{admonition} Tip on supplying extra shape collections @@ -271,17 +269,19 @@ shape_collections = [ ] # pass a list of shape collections to .validate() -validation_result = model.validate() +validation_result = model.validate(shape_collections) print(f"Model is valid? {validation_result.valid}") # print reasons -for diff in validation_result.diffset: - print(f" - {diff.reason()}") +for entity, errors in validation_result.diffset.items(): + print(entity) + for err in errors: + print(f" - {err.reason()}") ``` ### Fixing the Model -The model is failing because we don't have a heating coil required by the manifest, which we forgot to add in the previous tutorial. It's also failing the use case validation, which we'll cover in the next section. To fix the manifest validation, use the equipment templates in the Brick library to create a heating coil, add it to the model, and connect it to the AHU using RDFLib's `graph.add()` method. +One of the reasons the model is failing is we don't have a heating coil required by the manifest, which we forgot to add in the previous tutorial. It's also failing the use case validation, which we'll cover in the next section. To fix the manifest validation, use the equipment templates in the Brick library to create a heating coil, add it to the model, and connect it to the AHU using RDFLib's `graph.add()` method. ```{code-cell} # ahu name @@ -310,11 +310,13 @@ validation_result = model.validate() print(f"Model is valid? {validation_result.valid}") # print reasons -for diff in validation_result.diffset: - print(f" - {diff.reason()}") +for entity, errors in validation_result.diffset.items(): + print(entity) + for err in errors: + print(f" - {err.reason()}") ``` -Success! The model is no longer failing the manifest validation. +Success! Our model is now valid. ## Model Validation - Use Case @@ -333,16 +335,20 @@ for shape in shapes.get_shapes_of_definition_type(BMOTIF["System_Specification"] The model represents the Small Office Commercial Prototype Building model, which has single zone packaged AHUs, so we're interested in validating it against Section 4.8 of Guideline 36 for single zone variable air volume (VAV) AHUs. - +``` + ### Validating the Model @@ -350,7 +356,7 @@ with open("tutorial2_manifest.ttl", "a") as f: ```{code-cell} # load manifest into BuildingMOTIF as its own library! -manifest = Library.load(ontology_graph="tutorial2_manifest.ttl") +manifest = Library.load(ontology_graph="tutorial1_manifest.ttl") # gather these into a list for ease of use shape_collections = [ @@ -364,7 +370,15 @@ shape_collections = [ validation_result = model.validate(shape_collections) print(f"Model is valid? {validation_result.valid}") ``` --> -As shown in the previous section, the AHU fails validation because it doesn't match the `sz-vav-ahu-control-sequences` requirements. Take a look at the first bit of output, which is the official SHACL validation report text format. These aren't very understandable but BuildingMOTIF can make this output more interpretable! +Now we can run validation to see if our AHU is ready to run the "single zone AHU" control sequence: + +```{code-cell} +validation_result = model.validate() +print(f"Model is valid? {validation_result.valid}") +``` + +The AHU fails validation because it doesn't match the `sz-vav-ahu-control-sequences` requirements. +Take a look at the first bit of output, which is the official SHACL validation report text format. This can be difficult to interpret without a background in SHACL, so BuildingMOTIF provides a more easily understood version of the same information. ```{code-cell} # SHACL validation report @@ -372,14 +386,21 @@ print(validation_result.report_string) # separator print("-"*79) +``` +Here is BuildingMOTIF's interpretation of that report. + +```{code-cell} # BuildingMOTIF output print("Model is invalid for these reasons:") -for diff in validation_result.diffset: - print(f" - {diff.reason()}") +for entity, errors in validation_result.diffset.items(): + print(entity) + for err in errors: + print(f" - {err.reason()}") ``` -The model is failing because the AHU doesn't have the minimum number of supply fans associated with it. We *could* add the fan explicitly by adding those triples to the model like we've done previously, but we can also ask BuildingMOTIF to generate new templates that explicitly prompt us for the missing information. We'll cover this feature in the next tutorial so let's save the model. +The model is failing because the AHU doesn't have the required points. We could find those templates manually, evaluate them, and add the resulting graphs to the model. However, this can be a bit +tedious. To address this issue, BuildingMOTIF can find those templates automatically for us. We'll cover this feature in the next tutorial so let's save the model. ```{code-cell} #save model diff --git a/docs/tutorials/tutorial1_manifest.ttl b/docs/tutorials/tutorial1_manifest.ttl new file mode 100644 index 000000000..74b29673a --- /dev/null +++ b/docs/tutorials/tutorial1_manifest.ttl @@ -0,0 +1,42 @@ + +@prefix brick: . +@prefix owl: . +@prefix sh: . +@prefix constraint: . +@prefix : . + +: a owl:Ontology ; + owl:imports , + , + . + +:ahu-count a sh:NodeShape ; + sh:message "need 1 AHU" ; + sh:targetNode : ; + constraint:exactCount 1 ; + constraint:class brick:AHU . + +:fan-count a sh:NodeShape ; + sh:message "need 1 supply fan" ; + sh:targetNode : ; + constraint:exactCount 1 ; + constraint:class brick:Supply_Fan . + +:damper-count a sh:NodeShape ; + sh:message "need 1 damper" ; + sh:targetNode : ; + constraint:exactCount 1 ; + constraint:class brick:Damper . + +:clg-coil-count a sh:NodeShape ; + sh:message "need 1 cooling coil" ; + sh:targetNode : ; + constraint:exactCount 1 ; + constraint:class brick:Cooling_Coil . + +:htg-coil-count a sh:NodeShape ; + sh:message "need 1 heating coil" ; + sh:targetNode : ; + constraint:exactCount 1 ; + constraint:class brick:Heating_Coil . + diff --git a/docs/tutorials/tutorial1_model.ttl b/docs/tutorials/tutorial1_model.ttl index 2b3ff6544..546c1a593 100644 --- a/docs/tutorials/tutorial1_model.ttl +++ b/docs/tutorials/tutorial1_model.ttl @@ -1,3 +1,4 @@ +@prefix bldg: . @prefix brick: . @prefix owl: . @@ -6,7 +7,11 @@ a brick:AHU ; brick:hasPart , , - . + , + ; + brick:hasPoint . + +bldg:Core_ZN-PSC_AC a brick:Air_Handler_Unit . a brick:Cooling_Coil . @@ -14,3 +19,10 @@ a brick:Supply_Fan . + a brick:Outside_Damper ; + brick:hasPoint . + + a brick:Damper_Position_Command . + + a brick:Zone_Air_Temperature_Sensor . + diff --git a/docs/tutorials/tutorial2_model.ttl b/docs/tutorials/tutorial2_model.ttl index 48c182ca5..9da9dfba4 100644 --- a/docs/tutorials/tutorial2_model.ttl +++ b/docs/tutorials/tutorial2_model.ttl @@ -1,3 +1,4 @@ +@prefix bldg: . @prefix brick: . @prefix owl: . @@ -6,11 +7,25 @@ a brick:AHU ; brick:hasPart , , - . + , + , + ; + brick:hasPoint . + +bldg:Core_ZN-PSC_AC a brick:Air_Handler_Unit . a brick:Cooling_Coil . a brick:Damper . + a brick:Supply_Fan . + a brick:Heating_Coil . + a brick:Outside_Damper ; + brick:hasPoint . + + a brick:Damper_Position_Command . + + a brick:Zone_Air_Temperature_Sensor . + diff --git a/docs/tutorials/tutorial3_model.ttl b/docs/tutorials/tutorial3_model.ttl index 8f820c27f..2755cba1f 100644 --- a/docs/tutorials/tutorial3_model.ttl +++ b/docs/tutorials/tutorial3_model.ttl @@ -1,3 +1,4 @@ +@prefix bldg: . @prefix brick: . @prefix owl: . @prefix vav48: . @@ -8,23 +9,57 @@ brick:hasPart , , , - . + , + , + ; + brick:hasPoint , + , + , + , + , + , + , + . + +bldg:Core_ZN-PSC_AC a brick:Air_Handler_Unit . + + a brick:Cooling_Command . a brick:Cooling_Coil . a brick:Damper . - a brick:Supply_Fan, + a brick:Filter_Differential_Pressure_Sensor . + + a brick:Heating_Command . + + a brick:Heating_Coil . + + a brick:Mixed_Air_Temperature_Sensor . + + a brick:Outside_Air_Temperature_Sensor . + + a brick:Outside_Damper ; + brick:hasPoint . + + a brick:Damper_Position_Command . + + a brick:Return_Air_Temperature_Sensor . + + a brick:Supply_Air_Temperature_Sensor . + + a brick:Supply_Fan, vav48:sa-fan ; - brick:hasPoint , - , - . + brick:hasPoint , + , + . - a brick:Fan_Status . + a brick:Start_Stop_Command . - a brick:Start_Stop_Command . + a brick:Frequency_Command . - a brick:Frequency_Command . + a brick:Zone_Air_Temperature_Sensor . - a brick:Heating_Coil . + a brick:Fan_Status, + brick:Supply_Fan . diff --git a/libraries/ashrae/guideline36/4.1-vav-cooling-only.ttl b/libraries/ashrae/guideline36/4.1-vav-cooling-only.ttl index ade15bb14..55455b24c 100644 --- a/libraries/ashrae/guideline36/4.1-vav-cooling-only.ttl +++ b/libraries/ashrae/guideline36/4.1-vav-cooling-only.ttl @@ -45,10 +45,10 @@ ] ; . -:zone-temperature a sh:NodeShape ; +:zone-temperature a sh:NodeShape, owl:Class ; sh:or ( :zone-temperature1 :zone-temperature2 ) . -:zone-temperature1 a sh:NodeShape ; +:zone-temperature1 a sh:NodeShape, owl:Class ; sh:property [ sh:path brick:hasPoint ; sh:qualifiedValueShape [ sh:class brick:Zone_Air_Temperature_Sensor ] ; @@ -79,17 +79,21 @@ ] ; . -:occupancy-sensor a sh:NodeShape ; +:occupancy-sensor a sh:NodeShape, owl:Class ; sh:or ( :occupancy-sensor1 :occupancy-sensor2 ) . -:occupancy-sensor1 a sh:PropertyShape ; - sh:path brick:hasPoint ; - sh:qualifiedValueShape [ sh:class brick:Occupancy_Sensor ] ; - sh:qualifiedMinCount 0 ; +:occupancy-sensor1 a sh:NodeShape, owl:Class ; + sh:property [ + sh:path brick:hasPoint ; + sh:qualifiedValueShape [ sh:class brick:Occupancy_Sensor ] ; + sh:qualifiedMinCount 0 ; + ] . -:occupancy-sensor2 a sh:PropertyShape ; - sh:path ( [ sh:oneOrMorePath brick:feeds ] [ sh:zeroOrOnePath brick:hasPart ] ) ; - sh:qualifiedValueShape [ sh:or ( :zone-with-occ-sensor :room-with-occ-sensor ) ] ; - sh:qualifiedMinCount 0 ; +:occupancy-sensor2 a sh:NodeShape, owl:Class ; + sh:property [ + sh:path ( [ sh:oneOrMorePath brick:feeds ] [ sh:zeroOrOnePath brick:hasPart ] ) ; + sh:qualifiedValueShape [ sh:or ( :zone-with-occ-sensor :room-with-occ-sensor ) ] ; + sh:qualifiedMinCount 0 ; + ] . :zone-with-occ-sensor a sh:NodeShape, owl:Class ; sh:class brick:HVAC_Zone ; @@ -127,28 +131,33 @@ sh:path brick:hasPoint ; sh:qualifiedMinCount 1 ; sh:qualifiedValueShape [ sh:class brick:Open_Close_Status ] ; - ] ; - ] ; + ] + ] ] . -:zone-co2-level ; +:zone-co2-level a sh:NodeShape, owl:Class ; sh:or ( :zone-co2-level1 :zone-co2-level2 ) . -:zone-co2-level1 a sh:PropertyShape ; - sh:path brick:hasPoint ; - sh:qualifiedValueShape [ sh:class brick:CO2_Level_Sensor ] ; - sh:qualifiedMinCount 0 ; -. -:zone-co2-level2 a sh:PropertyShape ; - sh:path ( - [ sh:oneOrMorePath brick:feeds ] - [ sh:zeroOrOnePath brick:hasPart ] - ) ; - sh:qualifiedValueShape [ - sh:or ( :zone-with-co2-sensor :space-with-co2-sensor ) - ] ; - sh:qualifiedMinCount 0 ; + +:zone-co2-level1 a sh:NodeShape, owl:Class ; + sh:property [ + sh:path brick:hasPoint ; + sh:qualifiedValueShape [ sh:class brick:CO2_Level_Sensor ] ; + sh:qualifiedMinCount 0 ; + ] +. +:zone-co2-level2 a sh:NodeShape, owl:Class ; + sh:property [ + sh:path ( + [ sh:oneOrMorePath brick:feeds ] + [ sh:zeroOrOnePath brick:hasPart ] + ) ; + sh:qualifiedValueShape [ + sh:or ( :zone-with-co2-sensor :space-with-co2-sensor ) + ] ; + sh:qualifiedMinCount 0 ; + ] . :zone-with-co2-sensor a sh:NodeShape, owl:Class ; diff --git a/libraries/ashrae/guideline36/4.8-sz-vav-ahu.ttl b/libraries/ashrae/guideline36/4.8-sz-vav-ahu.ttl index 11a726785..4077a9e22 100644 --- a/libraries/ashrae/guideline36/4.8-sz-vav-ahu.ttl +++ b/libraries/ashrae/guideline36/4.8-sz-vav-ahu.ttl @@ -1,10 +1,12 @@ -@prefix sh: . +@prefix bmotif: . @prefix brick: . +@prefix components: . +@prefix owl: . @prefix rdf: . @prefix rdfs: . -@prefix owl: . +@prefix sh: . +@prefix vav41: . @prefix : . -@prefix bmotif: . :sz-vav-ahu a sh:NodeShape, owl:Class, bmotif:System_Specification ; sh:class brick:AHU ; @@ -14,12 +16,62 @@ sh:qualifiedMinCount 1 ; sh:qualifiedMaxCount 1 ; ] ; - sh:node :sa-temp, :oa-ra-damper, :oa-temp, :ma-temp, :ra-temp, :filter-pd, - :clg-signal, :htg-signal, :zone-temperature, :occupancy-sensor, :zone-co2-level ; + sh:property [ + sh:path brick:hasPoint ; + sh:qualifiedValueShape [ sh:class brick:Supply_Air_Temperature_Sensor ] ; + sh:qualifiedMinCount 1 ; + sh:qualifiedMaxCount 1 ; + ] ; + # outside air temp + sh:property [ + sh:path brick:hasPoint ; + sh:qualifiedValueShape [ sh:class brick:Outside_Air_Temperature_Sensor ] ; + sh:qualifiedMinCount 1 ; + sh:qualifiedMaxCount 1 ; + ] ; + # mixed air temp + sh:property [ + sh:path brick:hasPoint ; + sh:qualifiedValueShape [ sh:class brick:Mixed_Air_Temperature_Sensor ] ; + sh:qualifiedMinCount 1 ; + sh:qualifiedMaxCount 1 ; + ] ; + # return air temp + sh:property [ + sh:path brick:hasPoint ; + sh:qualifiedValueShape [ sh:class brick:Return_Air_Temperature_Sensor ] ; + sh:qualifiedMinCount 1 ; + sh:qualifiedMaxCount 1 ; + ] ; + # outside air damper + sh:property [ + sh:path brick:hasPart ; + sh:qualifiedValueShape [ + sh:class brick:Outside_Damper ; + sh:property [ + sh:path brick:hasPoint ; + sh:qualifiedValueShape [ sh:class brick:Damper_Position_Command ] ; + sh:qualifiedMinCount 1 ; + sh:qualifiedMaxCount 1 ; + ] ; + ] ; + sh:qualifiedMinCount 1 ; + sh:qualifiedMaxCount 1 ; + ] ; + # differential filter pressure + sh:property [ + sh:path brick:hasPoint ; + sh:qualifiedValueShape [ sh:class brick:Filter_Differential_Pressure_Sensor ] ; + sh:qualifiedMinCount 1 ; + sh:qualifiedMaxCount 1 ; + ] ; + + sh:node components:clg-signal, components:htg-signal, vav41:zone-temperature, vav41:occupancy-sensor, vav41:zone-co2-level ; bmotif:domain bmotif:HVAC ; . -:sa-fan a sh:NodeShape ; + +:sa-fan a sh:NodeShape, owl:Class ; sh:class brick:Supply_Fan ; sh:property [ sh:qualifiedMinCount 1 ; diff --git a/libraries/ashrae/guideline36/components.ttl b/libraries/ashrae/guideline36/components.ttl index 00d34adab..de695f7e4 100644 --- a/libraries/ashrae/guideline36/components.ttl +++ b/libraries/ashrae/guideline36/components.ttl @@ -29,3 +29,21 @@ sh:qualifiedMaxCount 1 ; ] ; . + +:clg-signal a sh:NodeShape, owl:Class ; + rdfs:label "Cooling Signal" ; + sh:property [ + sh:path brick:hasPoint ; + sh:qualifiedValueShape [ sh:class brick:Cooling_Command ] ; + sh:qualifiedMinCount 1 ; + sh:qualifiedMaxCount 1 ; + ] . + +:htg-signal a sh:NodeShape, owl:Class ; + rdfs:label "Heating Signal" ; + sh:property [ + sh:path brick:hasPoint ; + sh:qualifiedValueShape [ sh:class brick:Heating_Command ] ; + sh:qualifiedMinCount 1 ; + sh:qualifiedMaxCount 1 ; + ] . diff --git a/tests/integration/fixtures/bacnet/Dockerfile b/tests/integration/fixtures/bacnet/Dockerfile index 54582946f..6b8f7e4ca 100644 --- a/tests/integration/fixtures/bacnet/Dockerfile +++ b/tests/integration/fixtures/bacnet/Dockerfile @@ -3,7 +3,7 @@ FROM python:3.9 as base WORKDIR /opt COPY requirements.txt . -RUN pip3 install -r requirements.txt +RUN python3 -m pip install -r requirements.txt COPY virtual_bacnet.py virtual_bacnet.py -COPY BACpypes.ini . +COPY BACpypes.ini . \ No newline at end of file diff --git a/tests/integration/fixtures/buildingmotif/Dockerfile b/tests/integration/fixtures/buildingmotif/Dockerfile index 84a49ac66..17fe82376 100644 --- a/tests/integration/fixtures/buildingmotif/Dockerfile +++ b/tests/integration/fixtures/buildingmotif/Dockerfile @@ -1,8 +1,8 @@ -FROM python:3.9 +FROM python:3.10 WORKDIR /home/buildingmotif -RUN pip install poetry==1.4.0 && poetry config virtualenvs.create false +RUN pip install poetry==1.8.2 && poetry config virtualenvs.create false COPY pyproject.toml . COPY poetry.lock .