Defining HVAC Loops

2025-03-23


This is an in-progress post and may contain some errors or incomplete information.

Here’s a specific example of a simple HVAC system in 223P:

simple HVAC loop in 223P
Expand for Turtle definition of the 223P model
@prefix s223: <http://data.ashrae.org/standard223#> .
@prefix qudt: <http://qudt.org/schema/qudt/> .
@prefix qudtqk: <http://qudt.org/vocab/quantitykind/> .
@prefix unit: <http://qudt.org/vocab/unit/> .
@prefix owl: <http://www.w3.org/2002/07/owl#> .
@prefix ex: <http://example.com/hvac/> .

<http://example.com/hvac> a owl:Ontology ;
    owl:imports <http://data.ashrae.org/standard223/1.0/model/all> .

# Air Handling Unit (AHU) and its connections
ex:AHU a s223:AirHandlingUnit ;
    s223:hasConnectionPoint ex:AHU_Inlet1, ex:AHU_Inlet2;
    s223:hasConnectionPoint ex:AHU_Outlet .

ex:AHU_Inlet1 a s223:InletConnectionPoint ;
    s223:cnx ex:Return_Duct1 ;
    s223:isConnectionPointOf ex:AHU ;
    s223:hasMedium s223:Fluid-Air .

ex:AHU_Inlet2 a s223:InletConnectionPoint ;
    s223:cnx ex:Return_Duct2 ;
    s223:isConnectionPointOf ex:AHU ;
    s223:hasMedium s223:Fluid-Air .

ex:AHU_Outlet a s223:OutletConnectionPoint ;
    s223:cnx ex:Supply_Duct ;
    s223:isConnectionPointOf ex:AHU ;
    s223:hasMedium s223:Fluid-Air .

# Supply Main Duct connections
ex:Supply_Duct a s223:Duct ;
    s223:hasMedium s223:Fluid-Air ;
    s223:cnx ex:AHU_Outlet, ex:VAV1_Inlet, ex:VAV2_Inlet .

# return ducts
ex:Return_Duct1 a s223:Duct ;
    s223:hasMedium s223:Fluid-Air ;
    s223:cnx ex:Room1_Outlet, ex:AHU_Inlet1 .

# VAV 1 and its internal components
ex:VAV1 a s223:SingleDuctTerminal ;
    s223:contains ex:VAV1_Damper, ex:Heating_Coil1 ;
    s223:hasConnectionPoint ex:VAV1_Inlet, ex:VAV1_Outlet .

ex:VAV1_Inlet a s223:InletConnectionPoint ;
    s223:isConnectionPointOf ex:VAV1 ;
    s223:cnx ex:Supply_Duct ;
    s223:hasMedium s223:Fluid-Air .

ex:VAV1_Outlet a s223:OutletConnectionPoint ;
    s223:isConnectionPointOf ex:VAV1 ;
    s223:cnx ex:Supply_Duct ;
    s223:hasMedium s223:Fluid-Air .

ex:VAV1_Damper a s223:Damper ;
    s223:hasConnectionPoint ex:VAV1_Damper_In, ex:VAV1_Damper_Out .

ex:VAV1_Damper_In a s223:InletConnectionPoint ;
    s223:mapsTo ex:VAV1_Inlet ;
    s223:hasMedium s223:Fluid-Air .

ex:VAV1_Damper_Out a s223:OutletConnectionPoint ;
    s223:isConnectionPointOf ex:VAV1_Damper ;
    s223:hasMedium s223:Fluid-Air .

ex:VAV1_Damper_Coil_Duct a s223:Duct ;
    s223:hasMedium s223:Fluid-Air ;
    s223:cnx ex:VAV1_Damper_Out, ex:Heating_Coil1_In .

ex:Heating_Coil1 a s223:HeatingCoil ;
    s223:hasConnectionPoint ex:Heating_Coil1_In, ex:Heating_Coil1_Out .

ex:Heating_Coil1_In a s223:InletConnectionPoint ;
    s223:isConnectionPointOf ex:Heating_Coil1 ;
    s223:hasMedium s223:Fluid-Air .

ex:Heating_Coil1_Out a s223:OutletConnectionPoint ;
    s223:mapsTo ex:VAV1_Outlet ;
    s223:hasMedium s223:Fluid-Air .

# Room 1 and its connection
ex:Room1 a s223:PhysicalSpace ;
    s223:encloses ex:Room1HVAC .

ex:Room1HVAC a s223:DomainSpace ;
    s223:hasDomain s223:Domain-HVAC ;
    s223:hasConnectionPoint ex:Room1_Inlet, ex:Room1_Outlet .

ex:Room1_Inlet a s223:InletConnectionPoint ;
    s223:isConnectionPointOf ex:Room1HVAC ;
    s223:cnx ex:Supply_Duct_Room1 ;
    s223:hasMedium s223:Fluid-Air .

ex:Room1_Outlet a s223:OutletConnectionPoint ;
    s223:isConnectionPointOf ex:Room1HVAC ;
    s223:cnx ex:Return_Duct1 ;
    s223:hasMedium s223:Fluid-Air .

# Supply Duct to Room 1
ex:Supply_Duct_Room1 a s223:Duct ;
    s223:hasMedium s223:Fluid-Air ;
    s223:cnx ex:VAV1_Outlet, ex:Room1_Inlet .

####### VAV 2 

# return ducts
ex:Return_Duct2 a s223:Duct ;
    s223:hasMedium s223:Fluid-Air ;
    s223:cnx ex:Room2_Outlet, ex:AHU_Inlet2 .

# VAV 2 and its internal components
ex:VAV2 a s223:SingleDuctTerminal ;
    s223:contains ex:VAV2_Damper, ex:Heating_Coil2 ;
    s223:hasConnectionPoint ex:VAV2_Inlet, ex:VAV2_Outlet .

ex:VAV2_Inlet a s223:InletConnectionPoint ;
    s223:isConnectionPointOf ex:VAV2 ;
    s223:cnx ex:Supply_Duct ;
    s223:hasMedium s223:Fluid-Air .

ex:VAV2_Outlet a s223:OutletConnectionPoint ;
    s223:isConnectionPointOf ex:VAV2 ;
    s223:cnx ex:Supply_Duct ;
    s223:hasMedium s223:Fluid-Air .

ex:VAV2_Damper a s223:Damper ;
    s223:hasConnectionPoint ex:VAV2_Damper_In, ex:VAV2_Damper_Out .

ex:VAV2_Damper_In a s223:InletConnectionPoint ;
    s223:mapsTo ex:VAV2_Inlet ;
    s223:hasMedium s223:Fluid-Air .

ex:VAV2_Damper_Out a s223:OutletConnectionPoint ;
    s223:isConnectionPointOf ex:VAV2_Damper ;
    s223:hasMedium s223:Fluid-Air .

ex:VAV2_Damper_Coil_Duct a s223:Duct ;
    s223:hasMedium s223:Fluid-Air ;
    s223:cnx ex:VAV2_Damper_Out, ex:Heating_Coil2_In .

ex:Heating_Coil2 a s223:HeatingCoil ;
    s223:hasConnectionPoint ex:Heating_Coil2_In, ex:Heating_Coil2_Out .

ex:Heating_Coil2_In a s223:InletConnectionPoint ;
    s223:isConnectionPointOf ex:Heating_Coil2 ;
    s223:hasMedium s223:Fluid-Air .

ex:Heating_Coil2_Out a s223:OutletConnectionPoint ;
    s223:mapsTo ex:VAV2_Outlet ;
    s223:hasMedium s223:Fluid-Air .

# Room 2 and its connection
ex:Room2 a s223:PhysicalSpace ;
    s223:encloses ex:Room2HVAC .

ex:Room2HVAC a s223:DomainSpace ;
    s223:hasDomain s223:Domain-HVAC ;
    s223:hasConnectionPoint ex:Room2_Inlet, ex:Room2_Outlet .

ex:Room2_Inlet a s223:InletConnectionPoint ;
    s223:isConnectionPointOf ex:Room2HVAC ;
    s223:cnx ex:Supply_Duct_Room2 ;
    s223:hasMedium s223:Fluid-Air .

ex:Room2_Outlet a s223:OutletConnectionPoint ;
    s223:isConnectionPointOf ex:Room2HVAC ;
    s223:cnx ex:Return_Duct2 ;
    s223:hasMedium s223:Fluid-Air .

# Supply Duct to Room 2
ex:Supply_Duct_Room2 a s223:Duct ;
    s223:hasMedium s223:Fluid-Air ;
    s223:cnx ex:VAV2_Outlet, ex:Room2_Inlet .

We are using an RDF representation of the 223P model, meaning we have decomposed the graph into triples of subject predicate object statements. This is significant, because it affects the kinds of traversals and queries we can perform on the model.

I’m going to break the “loop definition” problem into two orthogonal components:

Our goal is to fill out this table with the access methods required to define each kind of loop.

Simple CycleTransitive ClosureStrongly Connected Component
Single component type?????????
Multiple component types?????????

Quick refresher on the difference between a cycle and a strongly connected component. A cycle is a path through a graph that starts and ends at the same node; other than that node, no other node is visited more than once. In the context of our HVAC system, this corresponds to the air loop through one of the VAVs (but not both!). Below is an illustration of the cycle through VAV 1. There is a second cycle in this graph through VAV 2.

A strongly connected component (SCC) is a set of nodes where there is a path from every node to every other node in the set. In the context of our HVAC system, this corresponds to the entire system, as there is a path from every component to every other component through the AHU.

A key limitation of the work here is we are assuming that:

  • all connection points are of the same substance (s223:Fluid-Air, etc)
  • there is only one input and one output connection point per equipment

These assumptions are not true in general, especially with heat exchangers. These can have multiple input/output pairs which might even be of different substances (think of a hot water coil in an air loop). Handling these requires following the s223:pairedConnectionPoint relationship to follow within the equipment.

Simple Cycle, Single Component Type

Let’s start with what is probably the conceptually simplest case: a simple cycle definition using only a single component type. This can look 2 ways. The first is just the big equipment without any of their equipment components (containing only AHU and VAV1).

highlighting a cycle through VAV 1 without subcomponents

The second is the big equipment with all of their equipment components: (AHU, VAV1, Damper, Coil).

highlighting a cycle through VAV 1 with subcomponents

Both of these loops are difficult to define programmatically for 3 reasons:

  1. Not all of the components are directly connected! The VAV1 is connected to AHU through the Room 1 node. Any traversal algorithm would need to be able to follow the path through Room 1 to find the connection between VAV1 and AHU.
  2. Even the directly connected components are connected through different types of edges. This is particularly relevant for the second loop where we include the equipment components.AHU has a direct link to VAV1 through a s223:connectedTo edge, but VAV1 is connected to its subcomponents through s223:contains edges.
  3. When the algorithm reaches AHU it needs to not include anything in the VAV2 branch of the graph. The only “repeat” node allowed in the loop definition is the starting node (VAV1).

We’ll do this by first using a script to “compile” our model to include all of the implied edges between the components. This should make it easier to write the queries to define the loops. Run with uv run compile.py

# /// script
# requires-python = ">=3.11"
# dependencies = [
#   "buildingmotif[topquadrant] @ https://github.com/NREL/BuildingMOTIF.git#develop",
# ]
# ///
from buildingmotif import BuildingMOTIF
from buildingmotif.dataclasses import Library, Model

# first, we need to compile the model against the 223P ontology to get the full graph.
bm = BuildingMOTIF("sqlite://")
s223 = Library.load(ontology_graph="https://open223.info/223p.ttl")
model = Model.from_file("2025-02-23-hvac223p.ttl")
g = model.compile([s223.get_shape_collection()])
v = model.validate([s223.get_shape_collection()], error_on_missing_imports=False)
print(v.valid)
print(v.report_string)
(s223.get_shape_collection().graph + g).serialize("hvac223p-compiled-all.ttl", format="turtle")
g.serialize("hvac223p-compiled.ttl", format="turtle")

Now, we can use a Python script to convert this to a NetworkX representation and use an existing implementation to find cycles in the graph. We are using the networkx.simple_cycles method here, which is a wrapper around a depth-first traversal of the graph. Run with uv run dfs.py

# /// script
# requires-python = ">=3.11"
# dependencies = [
#   "rdflib",
#   "networkx",
# ]
# ///
from rdflib import Graph, URIRef, Namespace
from rdflib.extras.external_graph_libs import rdflib_to_networkx_digraph
from networkx.algorithms import edge_dfs
import networkx as nx

EX = Namespace("http://example.com/hvac/") # from the turtle file
S223 = Namespace("http://data.ashrae.org/standard223#")

# load in the compiled graph
g = Graph().parse("hvac223p-compiled.ttl")

# turn the rdflib graph into a networkx graph so we can do traversals
g = rdflib_to_networkx_digraph(g)

# get the starting node (VAV1)
start = EX["VAV1"]
# This computes all the cycles in the graph. We only want the ones that include VAV1.
# We do this by checking if VAV1 is in the cycle.
for cycle in nx.simple_cycles(g):
    if start not in cycle:
        continue
    cycle.append(cycle[0]) # add the first node to the end to make it a cycle
    print(" -> ".join(cycle))
    print()

This gives the following output

http://example.com/hvac/Heating_Coil1 -> http://example.com/hvac/Heating_Coil1_Out -> http://example.com/hvac/VAV1_Outlet -> http://example.com/hvac/Supply_Duct -> http://example.com/hvac/AHU -> http://example.com/hvac/AHU_Inlet1 -> http://example.com/hvac/Return_Duct1 -> http://example.com/hvac/Room1HVAC -> http://example.com/hvac/Room1_Inlet -> http://example.com/hvac/Supply_Duct_Room1 -> http://example.com/hvac/VAV1
http://example.com/hvac/Heating_Coil1 -> http://example.com/hvac/Heating_Coil1_Out -> http://example.com/hvac/VAV1_Outlet -> http://example.com/hvac/Supply_Duct -> http://example.com/hvac/AHU -> http://example.com/hvac/AHU_Inlet1 -> http://example.com/hvac/Return_Duct1 -> http://example.com/hvac/Room1_Outlet -> http://example.com/hvac/Room1HVAC -> http://example.com/hvac/Room1_Inlet -> http://example.com/hvac/Supply_Duct_Room1 -> http://example.com/hvac/VAV1
http://example.com/hvac/Heating_Coil1 -> http://example.com/hvac/Heating_Coil1_Out -> http://example.com/hvac/VAV1_Outlet -> http://example.com/hvac/Supply_Duct -> http://example.com/hvac/AHU_Outlet -> http://example.com/hvac/AHU -> http://example.com/hvac/AHU_Inlet1 -> http://example.com/hvac/Return_Duct1 -> http://example.com/hvac/Room1HVAC -> http://example.com/hvac/Room1_Inlet -> http://example.com/hvac/Supply_Duct_Room1 -> http://example.com/hvac/VAV1
http://example.com/hvac/Heating_Coil1 -> http://example.com/hvac/Heating_Coil1_Out -> http://example.com/hvac/VAV1_Outlet -> http://example.com/hvac/Supply_Duct -> http://example.com/hvac/AHU_Outlet -> http://example.com/hvac/AHU -> http://example.com/hvac/AHU_Inlet1 -> http://example.com/hvac/Return_Duct1 -> http://example.com/hvac/Room1_Outlet -> http://example.com/hvac/Room1HVAC -> http://example.com/hvac/Room1_Inlet -> http://example.com/hvac/Supply_Duct_Room1 -> http://example.com/hvac/VAV1
http://example.com/hvac/Heating_Coil1 -> http://example.com/hvac/Heating_Coil1_Out -> http://example.com/hvac/VAV1_Outlet -> http://example.com/hvac/Supply_Duct -> http://example.com/hvac/VAV1
http://example.com/hvac/Heating_Coil1 -> http://example.com/hvac/Heating_Coil1_Out -> http://example.com/hvac/VAV1_Outlet -> http://example.com/hvac/Supply_Duct -> http://example.com/hvac/VAV1_Inlet -> http://example.com/hvac/VAV1
http://example.com/hvac/Heating_Coil1 -> http://example.com/hvac/Heating_Coil1_Out -> http://example.com/hvac/VAV1_Outlet -> http://example.com/hvac/VAV1
http://example.com/hvac/Room1_Inlet -> http://example.com/hvac/Supply_Duct_Room1 -> http://example.com/hvac/VAV1 -> http://example.com/hvac/VAV1_Damper -> http://example.com/hvac/VAV1_Damper_In -> http://example.com/hvac/VAV1_Inlet -> http://example.com/hvac/Supply_Duct -> http://example.com/hvac/AHU -> http://example.com/hvac/AHU_Inlet1 -> http://example.com/hvac/Return_Duct1 -> http://example.com/hvac/Room1HVAC
http://example.com/hvac/Room1_Inlet -> http://example.com/hvac/Supply_Duct_Room1 -> http://example.com/hvac/VAV1 -> http://example.com/hvac/VAV1_Damper -> http://example.com/hvac/VAV1_Damper_In -> http://example.com/hvac/VAV1_Inlet -> http://example.com/hvac/Supply_Duct -> http://example.com/hvac/AHU -> http://example.com/hvac/AHU_Inlet1 -> http://example.com/hvac/Return_Duct1 -> http://example.com/hvac/Room1_Outlet -> http://example.com/hvac/Room1HVAC
http://example.com/hvac/Room1_Inlet -> http://example.com/hvac/Supply_Duct_Room1 -> http://example.com/hvac/VAV1 -> http://example.com/hvac/VAV1_Damper -> http://example.com/hvac/VAV1_Damper_In -> http://example.com/hvac/VAV1_Inlet -> http://example.com/hvac/Supply_Duct -> http://example.com/hvac/AHU_Outlet -> http://example.com/hvac/AHU -> http://example.com/hvac/AHU_Inlet1 -> http://example.com/hvac/Return_Duct1 -> http://example.com/hvac/Room1HVAC
http://example.com/hvac/Room1_Inlet -> http://example.com/hvac/Supply_Duct_Room1 -> http://example.com/hvac/VAV1 -> http://example.com/hvac/VAV1_Damper -> http://example.com/hvac/VAV1_Damper_In -> http://example.com/hvac/VAV1_Inlet -> http://example.com/hvac/Supply_Duct -> http://example.com/hvac/AHU_Outlet -> http://example.com/hvac/AHU -> http://example.com/hvac/AHU_Inlet1 -> http://example.com/hvac/Return_Duct1 -> http://example.com/hvac/Room1_Outlet -> http://example.com/hvac/Room1HVAC
http://example.com/hvac/Room1_Inlet -> http://example.com/hvac/Supply_Duct_Room1 -> http://example.com/hvac/VAV1 -> http://example.com/hvac/VAV1_Inlet -> http://example.com/hvac/Supply_Duct -> http://example.com/hvac/AHU -> http://example.com/hvac/AHU_Inlet1 -> http://example.com/hvac/Return_Duct1 -> http://example.com/hvac/Room1HVAC
http://example.com/hvac/Room1_Inlet -> http://example.com/hvac/Supply_Duct_Room1 -> http://example.com/hvac/VAV1 -> http://example.com/hvac/VAV1_Inlet -> http://example.com/hvac/Supply_Duct -> http://example.com/hvac/AHU -> http://example.com/hvac/AHU_Inlet1 -> http://example.com/hvac/Return_Duct1 -> http://example.com/hvac/Room1_Outlet -> http://example.com/hvac/Room1HVAC
http://example.com/hvac/Room1_Inlet -> http://example.com/hvac/Supply_Duct_Room1 -> http://example.com/hvac/VAV1 -> http://example.com/hvac/VAV1_Inlet -> http://example.com/hvac/Supply_Duct -> http://example.com/hvac/AHU_Outlet -> http://example.com/hvac/AHU -> http://example.com/hvac/AHU_Inlet1 -> http://example.com/hvac/Return_Duct1 -> http://example.com/hvac/Room1HVAC
http://example.com/hvac/Room1_Inlet -> http://example.com/hvac/Supply_Duct_Room1 -> http://example.com/hvac/VAV1 -> http://example.com/hvac/VAV1_Inlet -> http://example.com/hvac/Supply_Duct -> http://example.com/hvac/AHU_Outlet -> http://example.com/hvac/AHU -> http://example.com/hvac/AHU_Inlet1 -> http://example.com/hvac/Return_Duct1 -> http://example.com/hvac/Room1_Outlet -> http://example.com/hvac/Room1HVAC
http://example.com/hvac/Room1_Inlet -> http://example.com/hvac/Supply_Duct_Room1 -> http://example.com/hvac/VAV1 -> http://example.com/hvac/VAV1_Outlet -> http://example.com/hvac/Supply_Duct -> http://example.com/hvac/AHU -> http://example.com/hvac/AHU_Inlet1 -> http://example.com/hvac/Return_Duct1 -> http://example.com/hvac/Room1HVAC
http://example.com/hvac/Room1_Inlet -> http://example.com/hvac/Supply_Duct_Room1 -> http://example.com/hvac/VAV1 -> http://example.com/hvac/VAV1_Outlet -> http://example.com/hvac/Supply_Duct -> http://example.com/hvac/AHU -> http://example.com/hvac/AHU_Inlet1 -> http://example.com/hvac/Return_Duct1 -> http://example.com/hvac/Room1_Outlet -> http://example.com/hvac/Room1HVAC
http://example.com/hvac/Room1_Inlet -> http://example.com/hvac/Supply_Duct_Room1 -> http://example.com/hvac/VAV1 -> http://example.com/hvac/VAV1_Outlet -> http://example.com/hvac/Supply_Duct -> http://example.com/hvac/AHU_Outlet -> http://example.com/hvac/AHU -> http://example.com/hvac/AHU_Inlet1 -> http://example.com/hvac/Return_Duct1 -> http://example.com/hvac/Room1HVAC
http://example.com/hvac/Room1_Inlet -> http://example.com/hvac/Supply_Duct_Room1 -> http://example.com/hvac/VAV1 -> http://example.com/hvac/VAV1_Outlet -> http://example.com/hvac/Supply_Duct -> http://example.com/hvac/AHU_Outlet -> http://example.com/hvac/AHU -> http://example.com/hvac/AHU_Inlet1 -> http://example.com/hvac/Return_Duct1 -> http://example.com/hvac/Room1_Outlet -> http://example.com/hvac/Room1HVAC
http://example.com/hvac/Room1_Inlet -> http://example.com/hvac/Supply_Duct_Room1 -> http://example.com/hvac/VAV1_Outlet -> http://example.com/hvac/VAV1 -> http://example.com/hvac/VAV1_Damper -> http://example.com/hvac/VAV1_Damper_In -> http://example.com/hvac/VAV1_Inlet -> http://example.com/hvac/Supply_Duct -> http://example.com/hvac/AHU -> http://example.com/hvac/AHU_Inlet1 -> http://example.com/hvac/Return_Duct1 -> http://example.com/hvac/Room1HVAC
http://example.com/hvac/Room1_Inlet -> http://example.com/hvac/Supply_Duct_Room1 -> http://example.com/hvac/VAV1_Outlet -> http://example.com/hvac/VAV1 -> http://example.com/hvac/VAV1_Damper -> http://example.com/hvac/VAV1_Damper_In -> http://example.com/hvac/VAV1_Inlet -> http://example.com/hvac/Supply_Duct -> http://example.com/hvac/AHU -> http://example.com/hvac/AHU_Inlet1 -> http://example.com/hvac/Return_Duct1 -> http://example.com/hvac/Room1_Outlet -> http://example.com/hvac/Room1HVAC
http://example.com/hvac/Room1_Inlet -> http://example.com/hvac/Supply_Duct_Room1 -> http://example.com/hvac/VAV1_Outlet -> http://example.com/hvac/VAV1 -> http://example.com/hvac/VAV1_Damper -> http://example.com/hvac/VAV1_Damper_In -> http://example.com/hvac/VAV1_Inlet -> http://example.com/hvac/Supply_Duct -> http://example.com/hvac/AHU_Outlet -> http://example.com/hvac/AHU -> http://example.com/hvac/AHU_Inlet1 -> http://example.com/hvac/Return_Duct1 -> http://example.com/hvac/Room1HVAC
http://example.com/hvac/Room1_Inlet -> http://example.com/hvac/Supply_Duct_Room1 -> http://example.com/hvac/VAV1_Outlet -> http://example.com/hvac/VAV1 -> http://example.com/hvac/VAV1_Damper -> http://example.com/hvac/VAV1_Damper_In -> http://example.com/hvac/VAV1_Inlet -> http://example.com/hvac/Supply_Duct -> http://example.com/hvac/AHU_Outlet -> http://example.com/hvac/AHU -> http://example.com/hvac/AHU_Inlet1 -> http://example.com/hvac/Return_Duct1 -> http://example.com/hvac/Room1_Outlet -> http://example.com/hvac/Room1HVAC
http://example.com/hvac/Room1_Inlet -> http://example.com/hvac/Supply_Duct_Room1 -> http://example.com/hvac/VAV1_Outlet -> http://example.com/hvac/VAV1 -> http://example.com/hvac/VAV1_Inlet -> http://example.com/hvac/Supply_Duct -> http://example.com/hvac/AHU -> http://example.com/hvac/AHU_Inlet1 -> http://example.com/hvac/Return_Duct1 -> http://example.com/hvac/Room1HVAC
http://example.com/hvac/Room1_Inlet -> http://example.com/hvac/Supply_Duct_Room1 -> http://example.com/hvac/VAV1_Outlet -> http://example.com/hvac/VAV1 -> http://example.com/hvac/VAV1_Inlet -> http://example.com/hvac/Supply_Duct -> http://example.com/hvac/AHU -> http://example.com/hvac/AHU_Inlet1 -> http://example.com/hvac/Return_Duct1 -> http://example.com/hvac/Room1_Outlet -> http://example.com/hvac/Room1HVAC
http://example.com/hvac/Room1_Inlet -> http://example.com/hvac/Supply_Duct_Room1 -> http://example.com/hvac/VAV1_Outlet -> http://example.com/hvac/VAV1 -> http://example.com/hvac/VAV1_Inlet -> http://example.com/hvac/Supply_Duct -> http://example.com/hvac/AHU_Outlet -> http://example.com/hvac/AHU -> http://example.com/hvac/AHU_Inlet1 -> http://example.com/hvac/Return_Duct1 -> http://example.com/hvac/Room1HVAC
http://example.com/hvac/Room1_Inlet -> http://example.com/hvac/Supply_Duct_Room1 -> http://example.com/hvac/VAV1_Outlet -> http://example.com/hvac/VAV1 -> http://example.com/hvac/VAV1_Inlet -> http://example.com/hvac/Supply_Duct -> http://example.com/hvac/AHU_Outlet -> http://example.com/hvac/AHU -> http://example.com/hvac/AHU_Inlet1 -> http://example.com/hvac/Return_Duct1 -> http://example.com/hvac/Room1_Outlet -> http://example.com/hvac/Room1HVAC
http://example.com/hvac/VAV1 -> http://example.com/hvac/VAV1_Damper -> http://example.com/hvac/VAV1_Damper_In -> http://example.com/hvac/VAV1_Inlet
http://example.com/hvac/VAV1 -> http://example.com/hvac/VAV1_Damper -> http://example.com/hvac/VAV1_Damper_In -> http://example.com/hvac/VAV1_Inlet -> http://example.com/hvac/Supply_Duct
http://example.com/hvac/VAV1 -> http://example.com/hvac/VAV1_Inlet
http://example.com/hvac/VAV1 -> http://example.com/hvac/VAV1_Inlet -> http://example.com/hvac/Supply_Duct
http://example.com/hvac/VAV1 -> http://example.com/hvac/VAV1_Outlet
http://example.com/hvac/VAV1 -> http://example.com/hvac/VAV1_Outlet -> http://example.com/hvac/Supply_Duct
http://example.com/hvac/VAV1 -> http://example.com/hvac/VAV1_Outlet -> http://example.com/hvac/Supply_Duct -> http://example.com/hvac/VAV1_Inlet

There are 34 cycles in the graph! Many of these are trivially short loops that don’t contain any interesting information but exist because of the additional edges added by the 223P ontology. Edges like s223:isConnectionPointOf (can be inferred) and s223:mapsTo (added by the modeler) will point “backwards” from the flow of air through the HVAC system, creating these loops.

To get our desired loop we need to filter out all non-equipment components from each VAV1 cycle

# /// script
# requires-python = ">=3.11"
# dependencies = [
#   "rdflib",
#   "networkx",
# ]
# ///
from rdflib import Graph, URIRef, Namespace
from rdflib.extras.external_graph_libs import rdflib_to_networkx_digraph
from networkx.algorithms import edge_dfs
import networkx as nx

EX = Namespace("http://example.com/hvac/") # from the turtle file
S223 = Namespace("http://data.ashrae.org/standard223#")

# load in the compiled graph
g = Graph().parse("hvac223p-compiled.ttl")
# use a second graph with the ontology to check if a node is of type S223:Equipment
ontg = Graph().parse("hvac223p-compiled-all.ttl")

# turn the rdflib graph into a networkx graph so we can do traversals
g = rdflib_to_networkx_digraph(g)


def is_equip(node):
    return ontg.query("""
PREFIX s223: <http://data.ashrae.org/standard223#>
ASK { ?node rdf:type/rdfs:subClassOf* s223:Equipment 
    }""", initBindings={"node": node}).askAnswer

# get the starting node (VAV1)
start = EX["VAV1"]
# This computes all the cycles in the graph. We only want the ones that include VAV1.
# We do this by checking if VAV1 is in the cycle.
new_cycles = set()
for cycle in nx.simple_cycles(g):
    if start not in cycle:
        continue
    cycle.append(cycle[0]) # add the first node to the end to make it a cycle
    # keep only nodes that have type S223:Equipment
    cycle = [node for node in cycle if is_equip(node)]
    new_cycles.add(tuple(cycle))

for cycle in new_cycles:
    print(" -> ".join(cycle))

This gives the following output

http://example.com/hvac/AHU -> http://example.com/hvac/VAV1 -> http://example.com/hvac/AHU
http://example.com/hvac/VAV1
http://example.com/hvac/AHU -> http://example.com/hvac/VAV1 -> http://example.com/hvac/VAV1_Damper -> http://example.com/hvac/AHU
http://example.com/hvac/VAV1 -> http://example.com/hvac/Heating_Coil1
http://example.com/hvac/AHU -> http://example.com/hvac/VAV1 -> http://example.com/hvac/Heating_Coil1 -> http://example.com/hvac/AHU
http://example.com/hvac/VAV1 -> http://example.com/hvac/VAV1_Damper

In here, we can see our desired loops from above, in addition to a few others.

This method of defining loops has a few steps, but you can express most things you want to find in the graph with a combination of compiling the model and using a graph traversal algorithm with some custom filtering and transformations.

Simple CycleTransitive ClosureStrongly Connected Component
Single component typenx.simple_cycles + filtering??????
Multiple component types?????????

Keep in mind that nx.simple_cycles is a brute-force algorithm that will find all cycles in the graph, so it can be slow for large graphs.

Simple Cycle, Multiple Component Types

This is pretty similar to the single component type case, but we just change the filtering step to include only the type of components we care about. For example, if we want to include domain spaces in addition to equipment in the loop, just change the SPARQL query to include the space types. This is implemented in the keep function below: Run with uv run dfs-filter-multiple.py

# /// script
# requires-python = ">=3.11"
# dependencies = [
#   "rdflib",
#   "networkx",
# ]
# ///
from rdflib import Graph, URIRef, Namespace
from rdflib.extras.external_graph_libs import rdflib_to_networkx_digraph
from networkx.algorithms import edge_dfs
import networkx as nx

EX = Namespace("http://example.com/hvac/") # from the turtle file
S223 = Namespace("http://data.ashrae.org/standard223#")

# load in the compiled graph
g = Graph().parse("hvac223p-compiled.ttl")
# use a second graph with the ontology to check if a node is of type S223:Equipment
ontg = Graph().parse("hvac223p-compiled-all.ttl")

# turn the rdflib graph into a networkx graph so we can do traversals
g = rdflib_to_networkx_digraph(g)


def keep(node):
    return ontg.query("""
PREFIX s223: <http://data.ashrae.org/standard223#>
ASK { 
    { ?node rdf:type/rdfs:subClassOf* s223:Equipment }
    UNION
    { ?node rdf:type/rdfs:subClassOf* s223:DomainSpace }
    FILTER NOT EXISTS { ?something s223:contains ?node }
}""", initBindings={"node": node}).askAnswer

# get the starting node (VAV1)
start = EX["VAV1"]
# This computes all the cycles in the graph. We only want the ones that include VAV1.
# We do this by checking if VAV1 is in the cycle.
new_cycles = set()
for cycle in nx.simple_cycles(g):
    if start not in cycle:
        continue
    cycle.append(cycle[0]) # add the first node to the end to make it a cycle
    # keep only nodes that have type S223:Equipment
    cycle = [node for node in cycle if keep(node)]
    new_cycles.add(tuple(cycle))

for cycle in new_cycles:
    print(" -> ".join(cycle))

The keep function also contains the line FILTER NOT EXISTS { ?something s223:contains ?node }, which will filter out all components contained by another component; this will remove the damper and heating coil from any loops we define. Obviously this is an optional feature: removing this line will include loop definitions with the VAV’s components.

Running the script gives the following output, which contains the loop we want: Room1HVAC -> VAV1 -> AHU

http://example.com/hvac/Room1HVAC -> http://example.com/hvac/VAV1 -> http://example.com/hvac/AHU
http://example.com/hvac/VAV1
http://example.com/hvac/VAV1 -> http://example.com/hvac/VAV1

We can also do this without having to touch any SPARQL at all!

# /// script
# requires-python = ">=3.11"
# dependencies = [
#   "rdflib",
#   "networkx",
# ]
# ///
from rdflib import Graph, URIRef, Namespace
from rdflib.extras.external_graph_libs import rdflib_to_networkx_digraph
from networkx.algorithms import edge_dfs
import networkx as nx

EX = Namespace("http://example.com/hvac/") # from the turtle file
S223 = Namespace("http://data.ashrae.org/standard223#")
OWL = Namespace("http://www.w3.org/2002/07/owl#")
RDFS = Namespace("http://www.w3.org/2000/01/rdf-schema#")

# load in the compiled graph
g = Graph().parse("hvac223p-compiled.ttl")
# use a second graph with the ontology to check if a node is of type S223:Equipment
ontg = Graph().parse("hvac223p-compiled-all.ttl")

# turn *both* rdflib graph into a networkx graphs so we can do traversals
g = rdflib_to_networkx_digraph(g)
ontg = rdflib_to_networkx_digraph(ontg)

# set of root classes; all nodes in the cycle must be a subclass of one of these
root_classes = [
    S223["Equipment"],
    S223["DomainSpace"],
    S223["Connectable"],
    S223["Connection"],
    S223["ConnectionPoint"],
]


# keep only nodes that are of type S223:Equipment or S223:DomainSpace
keep = [S223["Equipment"], S223["DomainSpace"]]


# get the root class of a node
def get_root_class(node):
    # returns the node from root_classes which has the shortest "rdfs:subClassOf" path to the node's rdf:type
    # if the node's rdf:type is not a subclass of any of the root_classes, return None
    shortest_path = None
    root_class = None
    for rc in root_classes:
        path = nx.shortest_path(ontg, node, rc)
        # only keep paths that are only classes (this means we are traversing the class structure
        # in the ontology). STart at the second node, because the first node is the node itself which
        # is not a class
        path_is_classes = all(
                ontg.has_edge(node, S223["Class"]) for node in path[1:]
        )
        if path_is_classes and (shortest_path is None or len(path) < shortest_path):
            shortest_path = len(path)
            root_class = rc
    return root_class


# get the starting node (VAV1)
start = EX["VAV1"]

# This computes all the cycles in the graph. We only want the ones that:
# - include VAV1
# - contain at least one of S223:Equipment *and* S223:DomainSpace
# Then filter out all nodes that are not of type S223:Equipment or S223:DomainSpace
new_cycles = set()
for cycle in nx.simple_cycles(g):
    if start not in cycle:
        continue
    # add types to the cycle
    typed_cycle = [
        (node, get_root_class(node)) for node in cycle
    ]
    # check if the cycle contains at least one of S223:Equipment and at least one S223:DomainSpace
    if not any(node[1] == S223["Equipment"] for node in typed_cycle) or not any(
        node[1] == S223["DomainSpace"] for node in typed_cycle
    ):
        continue
    # remove all nodes that are not of type S223:Equipment or S223:DomainSpace
    typed_cycle = [node[0] for node in typed_cycle if node[1] in keep]
    # add the first node to the end to make it a cycle
    typed_cycle.append(typed_cycle[0])
    # add the cycle to the set
    new_cycles.add(tuple(typed_cycle))

# print the cycles
for cycle in new_cycles:
    print(" -> ".join(node for node in cycle))

This uses the compiled graph (with the 223P ontology embedded within it) to find the cycles in the graph that contain only the types of components we care about. It does this by using a shortest path algorithm to determine which is the most appropriate “root” class of each node in the cycle, and using that information to filter out the cycles that don’t contain the desired components. We can also use this to keep cycles that contain at least one of each desired component type.

http://example.com/hvac/Room1HVAC -> http://example.com/hvac/VAV1 -> http://example.com/hvac/VAV1_Damper -> http://example.com/hvac/AHU -> http://example.com/hvac/Room1HVAC
http://example.com/hvac/Room1HVAC -> http://example.com/hvac/VAV1 -> http://example.com/hvac/Heating_Coil1 -> http://example.com/hvac/AHU -> http://example.com/hvac/Room1HVAC
http://example.com/hvac/Room1HVAC -> http://example.com/hvac/VAV1 -> http://example.com/hvac/AHU -> http://example.com/hvac/Room1HVAC
Simple CycleTransitive ClosureStrongly Connected Component
Single component typenx.simple_cycles + filtering??????
Multiple component typesnx.simple_cycles + filtering??????

Transitive Closure, Single Component Type

Now let’s examine how to do a transitive closure definition using only a single component type. A transitive closure is the set of all nodes reachable from a given node by following a specific edge type (or specific set of edge types). The physical analog to the transitive closure of a VAV is all equipment in the building that might receive (recycled) air that was previously conditioned by the VAV. For a chiller’s supply water, the physical analog is every coil that would receive water from the chiller.

In its most basic form, the transitive closure definition needs an edge pattern to follow. We can also constraint the closure to only include certain types of nodes, or contain certain nodes.

We have 3 types of components in our example: Equipment (AHU, VAV), Connection Points (Inlet, Outlet), and Connections (Ducts).

Let’s start with the s223:Equipment type. Instances of equipment are connected to each other by s223:connectedTo edges. We can define the transitive closure of equipment as the set of all equipment that are connected to each other using this edge.

PREFIX s223: <http://data.ashrae.org/standard223#>
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
PREFIX rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#>
SELECT ?equipment ?connectedEquipment
WHERE {
    BIND(<http://example.com/hvac/VAV1> as ?equipment)
    ?equipment rdf:type/rdfs:subClassOf* s223:Equipment .
    ?equipment s223:connectedTo ?connectedEquipment .
    ?connectedEquipment rdf:type/rdfs:subClassOf* s223:Equipment .
}

This query needs to be run on the compiled model with the embedded ontology; this model contains all of the inferred edges as well as the type definitions (and hierarchy) required to support the rdfs:subClassOf edges. The BIND statement at the top of the query sets the starting node for the transitive closure to VAV1.

The result of this query can be found here

This is not currently working due to limitations in the SHACL inference engine which doesn’t run enough times to add the edges we need

The most basic transitive closure we can define uses the s223:cnx relationship, which is a catch-all for all connections between components. Run this query to see the transitive closure of s223:cnx starting from VAV1.

Simple CycleTransitive ClosureStrongly Connected Component
Single component typenx.simple_cycles + filteringSPARQL Query???
Multiple component typesnx.simple_cycles + filtering??????

Transitive Closure, Multiple Component Types

Handling multiple component types in the transitive closure is mostly straightforward as an extension to the single component type case. What changes is the definition of the transitive closure: it now needs to handle multiple types of edges. In simpler ontologies like Brick, the single topological relationship brick:feeds is easy to follow between all sorts of entities. 223P has different types of edges between different types of components, so the transitive closure query needs to be more complex.

For example, to find the transitive closure of all equipment and connection points connected to a VAV, we need to follow:

PREFIX s223: <http://data.ashrae.org/standard223#>
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
PREFIX rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#>
SELECT ?start ?rest
WHERE {
    BIND(<http://example.com/hvac/VAV1> as ?start)
    ?start (s223:connectedTo|s223:hasConnectionPoint)* ?rest .
}

Note that this query returns everything reachable from the VAV using these edges; any filtering based on the type of the node will need to be done in post-processing similar to the cycle case.

Simple CycleTransitive ClosureStrongly Connected Component
Single component typenx.simple_cycles + filteringSPARQL Query???
Multiple component typesnx.simple_cycles + filteringSPARQL Query + filtering???

I will talk about strongly connected components in a future post.