ifc_systems/flow/role.rs
1//! Element flow roles, and consistency between a role and its ports.
2//!
3//! IFC states an element's kind (`IfcFlowSegment`, `IfcFlowTerminal`, ...) and
4//! its ports' directions independently. Nothing forces them to agree, so a
5//! file can say "terminal" while wiring it as a through-segment. Those
6//! disagreements are reported, never corrected: the file is the record, and
7//! silently repairing it would hide an authoring fault.
8
9use ifc_model::{EntityId, Model};
10
11use crate::error::SchemaResolutionError;
12use crate::port::Port;
13use crate::release::{self, Release};
14
15/// What kind of flow element this is, by schema ancestry.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
17pub enum ElementRole {
18 /// `IfcFlowSegment` -- pipe, duct, cable. Carries flow between two points.
19 Segment,
20 /// `IfcFlowFitting` -- elbow, tee, reducer. Joins segments.
21 Fitting,
22 /// `IfcFlowTerminal` -- radiator, diffuser, outlet. An endpoint.
23 Terminal,
24 /// `IfcFlowController` -- valve, damper, switch.
25 Controller,
26 /// `IfcFlowMovingDevice` -- pump, fan.
27 MovingDevice,
28 /// `IfcFlowStorageDevice` -- tank, cylinder.
29 StorageDevice,
30 /// `IfcFlowTreatmentDevice` -- filter, interceptor.
31 TreatmentDevice,
32 /// `IfcEnergyConversionDevice` -- boiler, chiller, heat exchanger.
33 EnergyConversionDevice,
34 /// A distribution element with no more specific flow role.
35 Other,
36}
37
38impl ElementRole {
39 /// Classify an element by walking its supertype chain.
40 ///
41 /// Ancestry, not exact type: a file states `IfcPipeSegment`, never
42 /// `IfcFlowSegment`, because the concrete subtypes are what exporters
43 /// write. An exact-type match finds nothing in a real file.
44 ///
45 /// Ancestry is read against the release the model declares. IFC2X3 has
46 /// no `IfcPipeSegment`: its files state `IfcFlowSegment` itself, and a
47 /// record name the declared release does not define has no role.
48 ///
49 /// `Ok(None)` when `element` is absent or not a distribution element.
50 ///
51 /// # Errors
52 ///
53 /// [`SchemaResolutionError`] when the model's `FILE_SCHEMA` binds no
54 /// release this crate is verified for (see [`crate::schema_of`]).
55 pub fn of(model: &Model, element: EntityId) -> Result<Option<Self>, SchemaResolutionError> {
56 Ok(Self::in_release(release::resolve(model)?, model, element))
57 }
58
59 fn in_release(schema: Release, model: &Model, element: EntityId) -> Option<Self> {
60 let entity = model.get(element)?;
61 let name = entity.type_name.to_ascii_uppercase();
62 // Order matters: the first match wins, so the most specific roles are
63 // tested before the catch-all distribution element.
64 for (ancestor, role) in [
65 ("IFCFLOWSEGMENT", Self::Segment),
66 ("IFCFLOWFITTING", Self::Fitting),
67 ("IFCFLOWTERMINAL", Self::Terminal),
68 ("IFCFLOWCONTROLLER", Self::Controller),
69 ("IFCFLOWMOVINGDEVICE", Self::MovingDevice),
70 ("IFCFLOWSTORAGEDEVICE", Self::StorageDevice),
71 ("IFCFLOWTREATMENTDEVICE", Self::TreatmentDevice),
72 ("IFCENERGYCONVERSIONDEVICE", Self::EnergyConversionDevice),
73 ] {
74 if schema.is_a(&name, ancestor) {
75 return Some(role);
76 }
77 }
78 if schema.is_a(&name, "IFCDISTRIBUTIONELEMENT") {
79 return Some(Self::Other);
80 }
81 None
82 }
83
84 /// Whether this role is expected to terminate a run rather than pass through.
85 ///
86 /// Advisory only. A terminal with two ports is unusual, not illegal, and
87 /// this is used to describe a file rather than to reject one.
88 pub fn is_endpoint(self) -> bool {
89 matches!(self, Self::Terminal)
90 }
91}
92
93/// A disagreement between an element's role and how its ports are directed.
94#[derive(Debug, Clone, PartialEq, Eq)]
95#[non_exhaustive]
96pub enum RoleInconsistency {
97 /// An element that should pass flow has no way in, or no way out.
98 ///
99 /// A segment with two SOURCE ports cannot receive anything: whatever the
100 /// exporter intended, nothing can flow through it as stated.
101 NoPath {
102 /// The element.
103 element: EntityId,
104 /// Its role.
105 role: ElementRole,
106 /// Whether any port accepts flow.
107 has_inlet: bool,
108 /// Whether any port emits flow.
109 has_outlet: bool,
110 },
111 /// An element carries ports whose direction the file never stated.
112 ///
113 /// Not an error: `FlowDirection` is OPTIONAL. It is reported because an
114 /// undirected port cannot orient an edge, so downstream queries will stop
115 /// at it and a caller deserves to know why.
116 UndirectedPorts {
117 /// The element.
118 element: EntityId,
119 /// How many of its ports have no stated direction.
120 count: usize,
121 },
122}
123
124/// Check every element that owns ports for role/direction disagreement.
125///
126/// Ports are grouped by their owning element, so an element whose ports were
127/// never attached is not reported: absent data is not a contradiction.
128///
129/// # Errors
130///
131/// [`SchemaResolutionError`] when the model's `FILE_SCHEMA` binds no release
132/// this crate is verified for (see [`crate::schema_of`]).
133pub fn role_inconsistencies(
134 model: &Model,
135 ports: &[Port],
136) -> Result<Vec<RoleInconsistency>, SchemaResolutionError> {
137 let release = release::resolve(model)?;
138 let mut by_element: std::collections::BTreeMap<EntityId, Vec<&Port>> =
139 std::collections::BTreeMap::new();
140 for port in ports {
141 if let Some(element) = port.element {
142 by_element.entry(element).or_default().push(port);
143 }
144 }
145
146 let mut out = Vec::new();
147 for (element, owned) in by_element {
148 let Some(role) = ElementRole::in_release(release, model, element) else {
149 continue;
150 };
151
152 let undirected = owned.iter().filter(|p| !p.flow.is_stated()).count();
153 if undirected > 0 {
154 out.push(RoleInconsistency::UndirectedPorts {
155 element,
156 count: undirected,
157 });
158 }
159
160 // A terminal is an endpoint by definition, so a single-direction
161 // terminal is correct and must not be reported.
162 if role.is_endpoint() || owned.len() < 2 {
163 continue;
164 }
165 let has_inlet = owned.iter().any(|p| p.flow.accepts());
166 let has_outlet = owned.iter().any(|p| p.flow.emits());
167 // Only report when the file DID state directions: an entirely
168 // undirected element is already covered above, and reporting it twice
169 // would make the undirected case look like a contradiction.
170 let any_stated = owned.iter().any(|p| p.flow.is_stated());
171 if any_stated && !(has_inlet && has_outlet) {
172 out.push(RoleInconsistency::NoPath {
173 element,
174 role,
175 has_inlet,
176 has_outlet,
177 });
178 }
179 }
180 Ok(out)
181}