Components¶
A component is a piece of network with a declared boundary of ports. It holds its own nodes
and edges, and it can hold other components, so a building is made of floors, a floor of
rooms, a room of its air and its walls. You build the tree in plain Python, in the way you
would wire blocks in Simulink or Grasshopper, and flatten() turns it into one ordinary
Network with a NameMap that remembers which component each node and edge
came from. The solver never sees components: layers, laws and drives work on the
flattened network exactly as they do on one built by hand.
Components live in noodl.components. The building application adds factory functions that
return components (room, door, crack, window, shaft, fan, duct, air_handler); see
Building physics.
A house from rooms and a door¶
Two rooms and a door between them, with two leaks from room B to the outside:
import torch
from noodl.apps.building_physics import crack, door, room
from noodl.components import Component
house = Component("house")
house.inner("ambient", z_ref=0.0, T0=283.15)
A = house.add(room("A", volume=60.0, T0=288.15))
B = house.add(room("B", volume=60.0, T0=285.15))
d = house.add(door("door", H=2.0, W=0.9, z_mid=1.0))
house.connect(A.ports.air, d.ports.a)
house.connect(d.ports.b, B.ports.air)
for name, z in (("leak_low", 0.2), ("leak_high", 1.8)):
leak = house.add(crack(name, area=0.05, Cd=0.6, z_path=z, exterior=True))
house.connect(leak.ports.a, B.ports.air)
net, names = house.flatten(dtype=torch.float64)
print(net.nodes)
print([net.graph.edges[e]["name"] for e in net.edges])
print(names.resolve("door.a"))
This is the model of the two-zone example,
assembled from parts. Rooms A and B each bring their air node. The door brings two
edges, a low and a high opening, between two terminals. Each leak brings one edge, from
the shared ambient node to a terminal. connect joins each terminal to a room, and
flatten returns a network of three nodes and four edges. door.a is not a node of the
flattened network; it was merged into A.air, and resolve tells you so.
Ports and connect¶
A port is a name a component gives to one of its nodes so that the outside can reach it.
room("A", ...) has the port air; door has a and b; shaft has one port per level.
c.ports.air returns a reference to the port, and list(c.ports) lists them all.
connect(p, q) merges two ports into one node. This is the Modelica connection set: the
potentials of the ports are equal, and the flows into the merged node sum to zero, which is
the conservation law the layer already solves at every node. A port may appear in several
connects (the two leaks above both join B.air), and connects are transitive.
A door belongs to neither of its rooms. It has two terminals, a and b, which are bare
connection points with no attributes (add_terminal makes one). A terminal carries no
volume, no temperature and no height; it only exists to be merged into a real node. The
same door can therefore join any two rooms, and the same room can be joined to any number
of doors.
Two real nodes cannot be merged, because each carries its own attributes (a volume, an
initial temperature) and there is no right way to combine them. Connecting A.air straight
to B.air fails at flatten(); the error includes
connecting merges 'A.air' and 'B.air' into one node, but each carries its own attributes:
two zones cannot be one air volume. Put a door, opening or other component between them.
(flatten collects every problem it finds and raises one ComponentError listing them.)
A terminal that nothing outside its component ever reaches is a closed opening. flatten
drops it together with its edges, so a door whose side b is never connected carries no
flow. model.check(state, drivers, names=names) lists such ports as information lines, so
a forgotten connect does not go unnoticed (see
Reading results).
A terminal that is not a port cannot be connected by anyone, so flatten reports it as an error:
use add_node(...) for an internal junction, or expose the terminal.
Only the ports of a direct child can be connected. Reaching into a grandchild is an error; re-export the port from its parent under a new name instead:
expose("air") with a plain name exposes one of the component's own nodes.
Names¶
Every node and edge of the flattened network is named by its path: the names of the
components down to it, joined by dots, with the root component's own name left out. In the
house above, the air of room A is A.air and the low opening of the door is door.low.
Names may therefore not contain a dot. Arrays use brackets as part of the name:
shaft("stair", levels=3, ...) has the nodes stair.levels[0], stair.levels[1] and
stair.levels[2].
When connect merges several ports into one node, the node keeps the path of its real node
(A.air), and every other port path that was merged into it, such as door.a, becomes an
alias. Aliases are accepted wherever a node name is: in drivers_from, in initial_drivers
values, in refs selectors and in node_index. named() always returns the canonical
names, so th.sources: {"door.a": 1000.0} heats A.air, and the result prints as A.air.
Layers store canonical names too: thermal_layer(net, fixed_temperature=["door.a"]) has A.air as
a boundary node. Giving one node twice under two names (["door.a", "A.air"]) is an error.
Heights and placement¶
parent.add(child, at=(x, y, z)) places a child at (x, y, z) metres relative to its
parent; placements add up down the tree. The default is the origin. In CONTAM the height of
a zone or a path is given relative to the level it sits on; in a component tree the level
is a component placed at its elevation, and everything inside it is relative to that.
Which attributes shift with the placement is registered by the application, because the
core does not know what a height is called. The building application registers z_ref on
nodes and z_path on edges. flatten adds the accumulated z of the component to them, so
a room placed at z = 3 with z_ref = 0.5 ends up at z_ref = 3.5. A node that has no
z_ref of its own sits at its component's placement. x and y change nothing physical;
they are stored as the node attribute position for drawing.
If a tree is placed above zero but no height attributes are registered, flatten warns
that no height was shifted, and names the ways to fix it: import the application that
registers them, call noodl.components.register_elevations, or pass node_elevations= and
edge_elevations= to flatten.
Shared nodes and wind¶
Some nodes belong to the whole building rather than to any part of it: the ambient air, the
ground, a plenum. inner(name, **attrs) declares such a node on a component, and an edge
anywhere below reaches it with outer(name). The nearest enclosing declaration wins, as in
Modelica, so a building can declare its own ambient and a district of buildings can
declare another one above it. An outer with no enclosing inner is an error at
flatten. The exterior openings crack(..., exterior=True) and window are edges from
outer("ambient") to their one terminal.
Wind is specified per path, as CONTAM does it: an exterior opening carries the attributes
Wind.from_network reads (azimuth, Cp, Ch, profile). Repeating the azimuth on every
opening is how an orientation error creeps in, so the building can declare its facades once
with inner_table("facades", south=180.0, north=0.0) and an opening can name its facade:
from noodl.apps.building_physics import window
tower = Component("tower")
tower.inner("ambient", z_ref=0.0, T0=283.15)
tower.inner_table("facades", south=180.0, north=0.0)
for i in range(2):
f = tower.add(room(f"floor{i}", volume=60.0), at=(0.0, 0.0, 3.0 * i))
w = tower.add(window(f"win{i}", area=0.01, z_path=1.0, facade="south", Cp=0.5),
at=(0.0, 0.0, 3.0 * i))
tower.connect(w.ports.a, f.ports.air)
tower_net, _ = tower.flatten(dtype=torch.float64)
print(tower_net.nodes)
print(tower_net.node_attr("z_ref").tolist())
print(tower_net.edge_attr("z_path", kind="airpath").tolist())
print(tower_net.edge_attr("azimuth", kind="airpath").tolist())
The second floor is placed at z = 3, so its window path at z_path = 1.0 is at 4.0 m, and
both windows read their azimuth from the table: flatten writes the table value into the
edge attribute azimuth. Turning the building is one edit of the table followed by a new
flatten(). Giving both facade= and azimuth= on one opening is an error, as is a facade
that is not in the table, or a facade= with no inner_table("facades", ...) above it.
Wind attributes on an interior crack are an error too: crack("c", ..., facade="south")
raises c: wind attributes ['facade'] apply only to an exterior opening (exterior=True).
Running it¶
Continue with the house. Nothing in the model code knows about components; it takes net:
from noodl.apps.building_physics import (
build_model, initial_drivers, initial_state, orifice_law_from_edges,
)
from noodl.drives import Stack
el = orifice_law_from_edges(net, "airpath")
model = build_model(net, air_laws=[el], drives=[Stack.from_network(net, "airpath")],
coupling="iterate", iterate_tol={"thermal": 0.01}, iterate_max=50)
air, th = model.refs.air, model.refs.thermal
rooms = [n for p in names.select(template="room") for n in names.nodes_of(p)]
state = initial_state(model)
drivers = initial_drivers(model, values={th.boundary_temperature: {"ambient": 283.15},
th.sources: {"door.a": 1000.0}})
model.check(state, drivers, names=names).raise_for_errors(strict=True)
state = model.step(state, drivers, dt=60.0)
print(rooms)
print(sorted(th.temperature.named(state[th.temperature])))
print(abs(float(names.boundary_flows("B", "airpath", state[air.flow]))) < 1e-9)
The heat source is given at the alias door.a and lands in room A. The last line says that
the net air flow into room B is zero to solver tolerance, as it must be for a room whose
air mass is fixed.
Reading results by component¶
The NameMap answers questions by component path. The root is the empty string "".
names.nodes_of(path)andnames.edges_of(path): the nodes and edges owned by the component and everything below it, in network order.names.select(template="room"): the paths of every component made by a factory. A factory records its name as the component'stemplate.names.boundary_edges(path, kind): the edges of one kind with exactly one end inside the component. The result holds their names, their positions innet.edge_index(kind)order and a sign, +1 for an edge pointing into the component.names.boundary_flows(path, kind, flows): the net inflow through those edges, for a flow tensor innet.edge_index(kind)order. These are the flows a surrogate model of the component would have to reproduce at its boundary, so they are the natural training target.names.tree(): the hierarchy as plain nested dictionaries (path, name, template, position, ports, own nodes and edges, children), ready to print or to draw.model.check(state, drivers, names=names): the usual setup report, plus one line for each port that nothing was connected to. A closed port is legitimate (a closed door), so it is information, not a warning.
A path that does not exist raises a KeyError with the closest matches, for example
unknown component 'Bb'; did you mean 'B'?.
How this relates to Modelica and CONTAM¶
| Here | Modelica | CONTAM |
|---|---|---|
a factory function (room, door) |
a class | a zone or path type |
| its keyword arguments | modifiers | the properties of the zone or path |
connect(p, q) |
a connection set | no counterpart |
inner / outer |
inner / outer |
the ambient, implicit |
a component placed at z with relative heights |
a position in the parent | a level and a height relative to it |
wind attributes on each exterior path, inner_table("facades", ...) |
no direct counterpart (MBL connects a weather bus weaBus to each Outside) |
wind pressure on each path, by wall azimuth |
CONTAM has no components. A building is one flat list of zones and paths, and its levels map
to the placement of a component here. Reading a CONTAM file therefore yields a flat network;
components are for building models in code. The Modelica Buildings Library has components,
and noodl follows its composition rules (connection sets, inner/outer), but it does not
generate Modelica classes; see File formats for what can be read.