Served-by topology
camber.model.entities says what equipment a building has and what points roll onto it. The
topology (camber.model.topology.Topology) says how that equipment is connected — which
plant serves which air handler, which air handler serves which zones. It is the missing piece that
lets fleet analytics stop treating a building as one flat pool of equipment and start reasoning per
system: "which of AHU-1's zones is dragging its reset?" rather than "which zone building-wide?"
flowchart TD
chw["CHW plant"]
ahu1["AHU-1"]
ahu2["AHU-2"]
vav1["VAV-1"]
vav2["VAV-2"]
vav3["VAV-3"]
gmap["group_map (zone to ahu)"]
fleet["Registry.run_fleet"]
chw -- feeds --> ahu1
chw -- feeds --> ahu2
ahu1 -- feeds --> vav1
ahu1 -- feeds --> vav2
ahu2 -- feeds --> vav3
vav1 --> gmap
vav2 --> gmap
vav3 --> gmap
gmap -- "per-system grouping" --> fleet
The served-by graph resolves each terminal's nearest system; group_map feeds that grouping to run_fleet.
The model
A Topology is a directed graph over equipment-id strings. An edge (parent, child) means
"parent serves / feeds child" (upstream → downstream), so a chilled-water plant → air handler → zone
system is three ids and two edges. The layers are not hard-coded: plant / AHU / zone are just
positions in the graph (roots are the most-upstream sources, leaves the terminals), so the same type
models plant → AHU → zone, AHU → zone → reheat-terminal, or any depth.
It is deliberately id-only — no dependency on the Equip entity — so it never introduces an
import cycle and stays agnostic about equipment classes. Questions that are class-aware ("the
AHU above this zone") are answered by passing a predicate, never by baking classes into the graph.
from camber.model.topology import Topology
topo = Topology.from_parent_map(
{
"VAV-1": "AHU-1",
"VAV-2": "AHU-1", # zones served by AHU-1
"VAV-3": "AHU-2", # a zone served by AHU-2
"AHU-1": "CHW",
"AHU-2": "CHW", # both AHUs served by the chilled-water plant
}
)
topo.zones_of("AHU-1") # ('VAV-1', 'VAV-2') -- an AHU's terminal zones
topo.zones_of("CHW") # ('VAV-1', 'VAV-2', 'VAV-3') -- transitive leaves
topo.ancestors("VAV-1") # frozenset({'AHU-1', 'CHW'})
topo.group_map(
["VAV-1", "VAV-2", "VAV-3"]
) # {'VAV-1': 'AHU-1', 'VAV-2': 'AHU-1', 'VAV-3': 'AHU-2'}
Construction
Topology.from_parent_map({child: parent})— the common shape;{zone: ahu}and{ahu: plant}maps merge into one graph. This is the exact inverse of the{zone: ahu}grouping a fleet analytic consumes.Topology.from_edges([(parent, child), ...])— the primitive.Topology.from_site(site)— the graph aSitecarries (a new defaultedSite.topologyfield), or the empty graph.
Automatic population
Beyond the explicit builders, a topology can be derived from a building's existing semantic model
or, failing that, its naming conventions. Each builder stamps a provenance so a consumer knows how
much to trust the result:
| Builder | Source | Provenance |
|---|---|---|
camber.interop.topology_from_brick(ttl) |
Brick feeds / isFedBy relations |
semantic |
camber.interop.topology_from_haystack(entities) |
Haystack ahuRef / equipRef refs |
semantic |
camber.topology_infer.topology_from_naming(equips) |
equipment id-prefix / shared space label | heuristic |
ASHRAE 223P connects |
— | deferred (see below) |
- Brick reads
brick:feeds(edge parent→child) andbrick:isFedBy(inverted); containment (hasPart) is deliberately not treated as served-by.site_from_ttlnow auto-populatesSite.topologyfrom these relations, so a Brick building withfeedsneeds no extra call. - Haystack reads
ahuRef(a terminal served by an air handler) andequipRef(equipment nested under a parent). A point'sequipRefis point ownership, not served-by, soequipRefis only followed for entities carrying theequipmarker.siteRef/spaceRefare ignored (a site is not served-by equipment). - Naming/space heuristic is the screening-grade fallback of last resort — a shared space
label or an
AHU_1_VAV_3-style id prefix is a guess, not a verified edge. It emits an edge only when exactly one air handler matches (ambiguous or unmatched terminals are skipped), and it stampsprovenance="heuristic"so a consumer can attach a screening caveat. Its real-world yield is modest (equipmentspaceis often unset), which is expected for a last-resort inference. - ASHRAE 223P connection modeling (
s223:connects/ connection-points, medium-typed) is a multi-hop graph heavier than a single parent reference, and CAMBER does not yet emit it, so topology extraction from 223P is deferred to a later release; Brickfeedscovers the authoritative-semantic layer today.
Queries
| Method | Returns |
|---|---|
children_of(id) / parents_of(id) |
direct downstream / upstream ids (() if unknown) |
descendants(id) / ancestors(id) |
everything transitively below / above |
roots() / leaves() |
most-upstream sources / terminals |
zones_of(id) |
the terminal (leaf) descendants of id — e.g. an AHU's zones |
nearest_ancestor(id, pred) |
closest upstream id satisfying pred (the class-aware primitive) |
group_of(id, pred=…) |
the grouping key for one terminal (its nearest matching ancestor) |
group_map(ids, pred=…) |
{id: group}, omitting ids with no resolvable group |
Consumed by fleet analytics
The {zone: ahu} grouping from group_map is what turns a building-wide fleet analytic into a
per-system one. Registry.run_fleet hands the topology to grouping-aware fleet rules: the
rogue-zone census (TR-RESET.md) uses it to scope per air handler. provenance
drives how much the result is trusted — a semantic grouping drops the census's confound caveat,
a heuristic one keeps a softened screening caveat. When no topology is passed, run_fleet
auto-builds a naming-heuristic one from the equipment ids so the census still auto-scopes.
Honesty is built into the type
Topology is often incomplete, so the type degrades rather than guesses:
- Partial graph — an id the graph doesn't know returns empty from every query, and
group_mapsimply omits it. A consumer can therefore measure coverage and keep its honest building-wide fallback for the uncovered remainder instead of inventing an edge. - Cyclic input — a malformed source with a cycle is broken into a best-effort DAG; the removed
edges are recorded in
dropped_cycle_edges(queryable), so a bad graph never hangs or crashes, it degrades and tells you what it dropped. - Provenance —
provenancerecords whether the graph is"explicit","semantic"(an authoritative source like Brickfeeds), or"heuristic"(a naming/space guess), so a downstream analytic can attach a screening caveat when it groups on a heuristic graph — the same honest-degradation register the rest of CAMBER uses.