Skip to main content

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}