How connections are resolved#

In S223 and WaTr, one connection is several resources: a connection point on each side, a Connection such as a Pipe, their media, and inverse links. connect(a, b) records only the intention. Resolution builds the rest with the Z3 solver, using constraints compiled from the SHACL shapes.

Points come from the shapes#

A class’s shapes say which points it needs: how many, which direction, and which media. The compiler turns them into a port contract:

from plumb.watr import Clarifier

print(Clarifier.describe_ports())
Clarifier port requirements
Unconditional ports:
  - direction="out" -> OutletConnectionPoint (count 2..any); medium roots: Mix-Fluid
Shape constraints:
  - all of:
    - direction="out" -> OutletConnectionPoint (count 1..any); medium roots: Fluid-Sludge
    - direction="out" -> OutletConnectionPoint (count 1..any); medium roots: Fluid-Water
  • Points with a fixed medium are created with the equipment. The rest wait for resolution.

  • Requirements are minimums. A Clarifier whose shapes list only outlets still gets an inlet when something connects into it. A maximum on the total still applies.

  • The allowed media on a side form a domain. Every S223 Fan inlet carries air, so connect(damper, fan) needs no medium=.

  • A port declared with .port(..., medium=...) overrides the domain.

  • sh:and, sh:or, and sh:xone stay as alternatives for the solver to choose between.

What the solver decides#

Each group of connected equipment is solved separately and cached until the model changes. Z3 chooses which points exist, their classes, which point each connection uses, each connection’s medium, and the connection class, subject to:

  • the shapes’ minimum and maximum counts, and their and/or/xone structure;

  • an outlet connects to an inlet (or a bidirectional point), and a point holds one connection;

  • each point’s medium is within its allowed domain, and a connection’s medium matches both of its points;

  • a property’s of_medium is compatible with the medium of the pipe or point it is on (223P’s “Incompatible Medium” rule);

  • anything you stated: medium=, a named point or port, connection=, or a paired port.

Media form a hierarchy: Water_ChilledWater satisfies a Fluid_Water domain.

Among valid answers, Z3 prefers fewer medium changes: two ports on one device prefer the same medium when their domains overlap. This is a preference, not a claim that equipment preserves its medium.

A property without of_medium takes the resolved medium of its host. A property with one only has to be compatible: the same medium, an ancestor or descendant, or a mixture sharing a constituent. Water and sludge share H₂O, so this rarely separates them.

The medium picks the connection class: water, gas, and oil give a Pipe, and air a Duct, unless connection= says otherwise.

Ambiguity is an error#

If more than one answer remains, resolution raises AmbiguousModelError rather than choose. A lone pump-to-pump connection is the simplest case: nothing says what flows. A model may be undecided while you author it, but RDF is only written for one interpretation. Contradictions raise UnsatisfiableModelError, listing the conflicting facts.

explain() shows each decision:

from plumb import Model, connect
from plumb.watr import Pump
from plumb.watr.drinking_water import CoagulationStage

with Model("urn:example:resolution#") as model:
    pump = Pump("intake-pump")
    coagulation = CoagulationStage("coagulation")
raw_water = connect(pump, coagulation, name="raw-water")

resolved = model.resolve()
resolved.explain(raw_water)
raw-water: intake-pump-out -> coagulation-inlet
  medium: Fluid-Water (explicit port)
  - selected OutletConnectionPoint intake-pump-out
  - selected InletConnectionPoint coagulation-inlet
  - coagulation-inlet.medium = Fluid-Water

No medium was given. The coagulation inlet has a fixed water medium, and the pump accepts water. The pipe in the resolved RDF:

from rdflib import URIRef

graph = resolved.graph()
pipe = resolved.connection(raw_water)
pipe_rdf = graph.cbd(URIRef(pipe.meta.instance_iri))
pipe_rdf.namespace_manager = graph.namespace_manager
print(pipe_rdf.serialize(format="turtle"))
@prefix : <urn:example:resolution#> .
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
@prefix s223: <http://data.ashrae.org/standard223#> .

:raw-water a s223:Pipe ;
    rdfs:label "raw-water" ;
    s223:connectsAt :coagulation-inlet,
        :intake-pump-out ;
    s223:hasMedium s223:Fluid-Water .

Required properties that require each other#

A SHACL minCount 1 usually becomes a required constructor argument, with two exceptions:

  • A requirement the class itself fixes, such as a sedimentation tank’s settling process, is filled in.

  • A cycle is left to validation. An S223 Junction requires a connection point, and a point requires its owner, so Junction("j", medium=...) works and its points are added during resolution.

Where validation fits#

The solver fixes topology and media before RDF exists. Validation then checks the RDF with shifty. A resolved model has no violations from its connection pattern. See Model lifecycle.