ifc_schedule/sequence/relation.rs
1//! `IfcRelSequence`: predecessor/successor links and their lag.
2//!
3//! # Slots, verified against IFC4 EXPRESS
4//!
5//! ```text
6//! IfcRelSequence (IfcRelConnects -> IfcRelationship -> IfcRoot)
7//! 0 GlobalId 1 OwnerHistory 2 Name
8//! 3 Description 4 RelatingProcess 5 RelatedProcess
9//! 6 TimeLag 7 SequenceType 8 UserDefinedSequenceType
10//!
11//! IfcLagTime (IfcSchedulingTime)
12//! 0 Name 1 DataOrigin 2 UserDefinedDataOrigin
13//! 3 LagValue 4 DurationType
14//! ```
15//!
16//! # Direction is stated, not inferred
17//!
18//! Unlike `IfcRelConnectsPorts`, where authoring order carries no physical
19//! meaning, `IfcRelSequence` IS directed by definition: `RelatingProcess` is
20//! the predecessor and `RelatedProcess` is the successor. The schema's own
21//! inverse names confirm it -- `IsPredecessorTo` is `FOR RelatingProcess`.
22//!
23//! # Sequence type says WHICH ends are linked
24//!
25//! `IfcSequenceEnum` is not decoration: `FINISH_START` means the successor
26//! starts after the predecessor finishes, while `START_START` means they start
27//! together. A tool that treats every link as finish-to-start will compute a
28//! schedule that the file does not state.
29//!
30//! # Lag is signed
31//!
32//! `IfcLagTime.LagValue` may be negative: a negative lag is a lead, meaning
33//! the linked ends overlap. It is a stated fact, not a defect to refuse.
34
35use std::collections::HashSet;
36
37use ifc_model::{EntityId, Model, Value};
38
39/// `IfcRelSequence` slots.
40pub(crate) mod slot {
41 /// `RelatingProcess`, the predecessor.
42 pub const RELATING: usize = 4;
43 /// `RelatedProcess`, the successor.
44 pub const RELATED: usize = 5;
45 /// `TimeLag`, an `IfcLagTime` reference.
46 pub const TIME_LAG: usize = 6;
47 /// `SequenceType`.
48 pub const SEQUENCE_TYPE: usize = 7;
49}
50
51/// `IfcLagTime` slots.
52pub mod lag_slot {
53 /// `LagValue`. Required by the schema.
54 pub const LAG_VALUE: usize = 3;
55 /// `DurationType`. Required by the schema.
56 pub const DURATION_TYPE: usize = 4;
57}
58
59/// The maximum sequence-graph depth walked before reporting a runaway chain.
60pub const MAX_SEQUENCE_DEPTH: usize = 4096;
61
62/// Which ends of two tasks a sequence links.
63///
64/// `IfcSequenceEnum`, verified against IFC4 EXPRESS.
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub enum SequenceType {
67 /// Successor starts after predecessor starts.
68 StartStart,
69 /// Successor finishes after predecessor starts.
70 StartFinish,
71 /// Successor starts after predecessor finishes. The common case.
72 FinishStart,
73 /// Successor finishes after predecessor finishes.
74 FinishFinish,
75 /// `.USERDEFINED.`
76 UserDefined,
77 /// `.NOTDEFINED.`
78 NotDefined,
79}
80
81impl SequenceType {
82 fn parse(token: &str) -> Option<Self> {
83 Some(match token {
84 "START_START" => Self::StartStart,
85 "START_FINISH" => Self::StartFinish,
86 "FINISH_START" => Self::FinishStart,
87 "FINISH_FINISH" => Self::FinishFinish,
88 "USERDEFINED" => Self::UserDefined,
89 "NOTDEFINED" => Self::NotDefined,
90 _ => return None,
91 })
92 }
93}
94
95/// The lag between two sequenced tasks.
96#[derive(Debug, Clone, PartialEq)]
97pub struct Lag {
98 /// The `IfcLagTime` entity.
99 pub id: EntityId,
100 /// The lag as an authored ISO 8601 duration, when stated as a duration.
101 pub duration: Option<String>,
102 /// The lag as a ratio, when stated as one.
103 ///
104 /// `IfcTimeOrRatioSelect` admits both. A ratio lag means "start when the
105 /// predecessor is 50% done" and cannot be converted to a duration without
106 /// knowing that task's own duration.
107 pub ratio: Option<f64>,
108}
109
110/// One directed sequence link.
111#[derive(Debug, Clone, PartialEq)]
112pub struct Sequence {
113 /// The `IfcRelSequence` entity.
114 pub id: EntityId,
115 /// The predecessor task.
116 pub predecessor: EntityId,
117 /// The successor task.
118 pub successor: EntityId,
119 /// Which ends are linked, if stated.
120 pub sequence_type: Option<SequenceType>,
121 /// The lag, if stated.
122 pub lag: Option<Lag>,
123}
124
125/// A cycle in the sequence graph.
126///
127/// A schedule whose tasks depend on each other in a loop has no valid
128/// ordering. This is data to report, not a condition to crash on.
129#[derive(Debug, Clone, PartialEq, Eq)]
130pub struct SequenceCycle {
131 /// The task the walk returned to.
132 pub repeated: EntityId,
133 /// The path taken, ending at the repeat.
134 pub path: Vec<EntityId>,
135}
136
137/// Every sequence link in the model, in file order.
138#[must_use]
139pub fn sequences(model: &Model) -> Vec<Sequence> {
140 let mut out = Vec::new();
141 for (id, entity) in model.of_type("IFCRELSEQUENCE") {
142 let (Some(Value::Ref(predecessor)), Some(Value::Ref(successor))) = (
143 entity.attribute(slot::RELATING),
144 entity.attribute(slot::RELATED),
145 ) else {
146 continue;
147 };
148 let sequence_type = match entity.attribute(slot::SEQUENCE_TYPE) {
149 Some(Value::Enum(token)) => SequenceType::parse(token),
150 _ => None,
151 };
152 let lag = match entity.attribute(slot::TIME_LAG) {
153 Some(Value::Ref(lag_id)) => read_lag(model, *lag_id),
154 _ => None,
155 };
156 out.push(Sequence {
157 id,
158 predecessor: *predecessor,
159 successor: *successor,
160 sequence_type,
161 lag,
162 });
163 }
164 out
165}
166
167fn read_lag(model: &Model, id: EntityId) -> Option<Lag> {
168 let entity = model.get(id)?;
169 if !entity.type_name.eq_ignore_ascii_case("IFCLAGTIME") {
170 return None;
171 }
172 let value = entity.attribute(lag_slot::LAG_VALUE);
173 // IfcTimeOrRatioSelect: IfcDuration is a string, IfcRatioMeasure a real.
174 // The wrapper distinguishes them, so read both rather than guessing.
175 let duration = value
176 .and_then(|v| v.unwrap_typed().as_text())
177 .map(str::to_string);
178 let ratio = if duration.is_some() {
179 None
180 } else {
181 value.and_then(|v| v.unwrap_typed().as_f64())
182 };
183 // DurationType is read to confirm the lag is a duration at all; a ratio
184 // lag leaves it meaningless.
185 let _ = entity.attribute(lag_slot::DURATION_TYPE);
186 Some(Lag {
187 id,
188 duration,
189 ratio,
190 })
191}
192
193/// Tasks that must finish (or start) before `task`, in file order.
194#[must_use]
195pub fn predecessors_of(model: &Model, task: EntityId) -> Vec<EntityId> {
196 sequences(model)
197 .into_iter()
198 .filter(|s| s.successor == task)
199 .map(|s| s.predecessor)
200 .collect()
201}
202
203/// Tasks that follow `task`, in file order.
204#[must_use]
205pub fn successors_of(model: &Model, task: EntityId) -> Vec<EntityId> {
206 sequences(model)
207 .into_iter()
208 .filter(|s| s.predecessor == task)
209 .map(|s| s.successor)
210 .collect()
211}
212
213/// Every task reachable downstream of `task`, depth-first.
214///
215/// Excludes the start. Returns `Err` with the offending path if the graph
216/// cycles: a schedule that loops has no valid ordering, and the loop is the
217/// answer the caller needs.
218///
219/// # Errors
220///
221/// [`SequenceCycle`] when a task is reachable from itself.
222pub fn downstream_of(model: &Model, task: EntityId) -> Result<Vec<EntityId>, SequenceCycle> {
223 let all = sequences(model);
224 let mut out = Vec::new();
225 let mut path = Vec::new();
226 let mut on_path = HashSet::new();
227 let mut seen = HashSet::new();
228 walk(&all, task, &mut out, &mut path, &mut on_path, &mut seen)?;
229 Ok(out)
230}
231
232fn walk(
233 all: &[Sequence],
234 node: EntityId,
235 out: &mut Vec<EntityId>,
236 path: &mut Vec<EntityId>,
237 on_path: &mut HashSet<EntityId>,
238 seen: &mut HashSet<EntityId>,
239) -> Result<(), SequenceCycle> {
240 if path.len() >= MAX_SEQUENCE_DEPTH {
241 return Ok(());
242 }
243 path.push(node);
244 on_path.insert(node);
245
246 for successor in all
247 .iter()
248 .filter(|s| s.predecessor == node)
249 .map(|s| s.successor)
250 {
251 if on_path.contains(&successor) {
252 let mut cycle = path.clone();
253 cycle.push(successor);
254 return Err(SequenceCycle {
255 repeated: successor,
256 path: cycle,
257 });
258 }
259 // A diamond reconverges on the same task by two routes; report it
260 // once, but still recurse the first time it is seen.
261 if seen.insert(successor) {
262 out.push(successor);
263 walk(all, successor, out, path, on_path, seen)?;
264 }
265 }
266
267 path.pop();
268 on_path.remove(&node);
269 Ok(())
270}
271
272/// The first cycle in the whole sequence graph, if any.
273///
274/// Checks every task, so a cycle in a disconnected component is still found.
275#[must_use]
276pub fn find_cycle(model: &Model) -> Option<SequenceCycle> {
277 for (id, _) in model.of_type("IFCTASK") {
278 if let Err(cycle) = downstream_of(model, id) {
279 return Some(cycle);
280 }
281 }
282 None
283}