Skip to main content

ifc_schedule/task/
definition.rs

1//! `IfcTask` and `IfcTaskTime`.
2//!
3//! # Slots, verified against IFC4 EXPRESS
4//!
5//! `IfcTask` is `IfcProcess` -> `IfcObject` -> `IfcRoot`. `IfcProcess`
6//! contributes `Identification` and `LongDescription` before the subtype's
7//! own fields:
8//!
9//! ```text
10//! 0 GlobalId       1 OwnerHistory     2 Name
11//! 3 Description    4 ObjectType       5 Identification    (IfcProcess)
12//! 6 LongDescription                   7 Status
13//! 8 WorkMethod     9 IsMilestone     10 Priority
14//! 11 TaskTime     12 PredefinedType
15//! ```
16//!
17//! `IsMilestone` is slot 9 and REQUIRED -- it is the one non-optional field
18//! `IfcTask` adds. `TaskTime` is 11, a reference to `IfcTaskTime`.
19//!
20//! ```text
21//! IfcTaskTime
22//! 0 Name                    1 DataOrigin           2 UserDefinedDataOrigin
23//! 3 DurationType            4 ScheduleDuration     5 ScheduleStart
24//! 6 ScheduleFinish          7 EarlyStart           8 EarlyFinish
25//! 9 LateStart              10 LateFinish          11 FreeFloat
26//! 12 TotalFloat            13 IsCritical          14 StatusTime
27//! 15 ActualDuration        16 ActualStart         17 ActualFinish
28//! 18 RemainingTime         19 Completion
29//! ```
30//!
31//! # A milestone has no duration, and that is a rule
32//!
33//! `IfcTaskTime` carries WHERE rule `WR1`:
34//!
35//! ```text
36//! WR1 : (NOT(EXISTS(SELF\IfcTaskTime.ScheduleDuration))) OR
37//!       (NOT(EXISTS(SELF\IfcTaskTime.ScheduleStart))) OR
38//!       ... task is not a milestone
39//! ```
40//!
41//! Practically: a task flagged `IsMilestone = .T.` states an instant, not a
42//! span, so a stated schedule duration contradicts the flag. This module
43//! reports that contradiction rather than choosing which field to believe.
44
45use ifc_model::{Entity, EntityId, Model, Value};
46
47/// `IfcTask` slots.
48pub(crate) mod task_slot {
49    /// `GlobalId` (from `IfcRoot`).
50    pub const GLOBAL_ID: usize = 0;
51    /// `Name` (from `IfcRoot`).
52    pub const NAME: usize = 2;
53    /// `Description` (from `IfcRoot`).
54    pub const DESCRIPTION: usize = 3;
55    /// `Identification` (from `IfcProcess`).
56    pub const IDENTIFICATION: usize = 5;
57    /// `LongDescription` (from `IfcProcess`).
58    pub const LONG_DESCRIPTION: usize = 6;
59    /// `Status`.
60    pub const STATUS: usize = 7;
61    /// `WorkMethod`.
62    pub const WORK_METHOD: usize = 8;
63    /// `IsMilestone`, required.
64    pub const IS_MILESTONE: usize = 9;
65    /// `Priority`.
66    pub const PRIORITY: usize = 10;
67    /// `TaskTime`.
68    pub const TASK_TIME: usize = 11;
69    /// `PredefinedType`.
70    pub const PREDEFINED_TYPE: usize = 12;
71}
72
73/// `IfcTaskTime` slots.
74pub(crate) mod time_slot {
75    /// `DurationType`, `.WORKTIME.` or `.ELAPSEDTIME.`.
76    pub const DURATION_TYPE: usize = 3;
77    /// `ScheduleDuration`.
78    pub const SCHEDULE_DURATION: usize = 4;
79    /// `ScheduleStart`.
80    pub const SCHEDULE_START: usize = 5;
81    /// `ScheduleFinish`.
82    pub const SCHEDULE_FINISH: usize = 6;
83    /// `EarlyStart`.
84    pub const EARLY_START: usize = 7;
85    /// `LateFinish`.
86    pub const LATE_FINISH: usize = 10;
87    /// `FreeFloat`.
88    pub const FREE_FLOAT: usize = 11;
89    /// `TotalFloat`.
90    pub const TOTAL_FLOAT: usize = 12;
91    /// `IsCritical`.
92    pub const IS_CRITICAL: usize = 13;
93    /// `ActualDuration`.
94    pub const ACTUAL_DURATION: usize = 15;
95    /// `ActualStart`.
96    pub const ACTUAL_START: usize = 16;
97    /// `ActualFinish`.
98    pub const ACTUAL_FINISH: usize = 17;
99    /// `Completion`, a percentage.
100    pub const COMPLETION: usize = 19;
101}
102
103/// Whether a duration counts working time or elapsed time.
104///
105/// `IfcTaskDurationEnum`. The distinction matters: two days of work time may
106/// span four calendar days across a weekend, and a caller converting one to
107/// the other needs the calendar.
108#[derive(Debug, Clone, Copy, PartialEq, Eq)]
109pub enum DurationType {
110    /// `.ELAPSEDTIME.`: calendar time, weekends included.
111    ElapsedTime,
112    /// `.WORKTIME.`: working time as defined by a calendar.
113    WorkTime,
114    /// `.NOTDEFINED.`
115    NotDefined,
116}
117
118impl DurationType {
119    fn parse(token: &str) -> Option<Self> {
120        Some(match token {
121            "ELAPSEDTIME" => Self::ElapsedTime,
122            "WORKTIME" => Self::WorkTime,
123            "NOTDEFINED" => Self::NotDefined,
124            _ => return None,
125        })
126    }
127}
128
129/// A contradiction between a task and its stated time.
130#[derive(Debug, Clone, PartialEq, Eq)]
131pub enum TaskTimeAnomaly {
132    /// The task is a milestone but its time states a schedule duration.
133    ///
134    /// `IfcTaskTime` `WR1`. A milestone is an instant; a duration contradicts
135    /// that, and neither field is authoritative over the other.
136    MilestoneWithDuration {
137        /// The task.
138        task: EntityId,
139        /// Its `IfcTaskTime`.
140        time: EntityId,
141        /// The duration the file states anyway.
142        duration: String,
143    },
144    /// `TaskTime` points at an entity that is not an `IfcTaskTime`.
145    NotATaskTime {
146        /// The task.
147        task: EntityId,
148        /// What it points at.
149        target: EntityId,
150        /// The type actually found.
151        found: String,
152    },
153}
154
155/// A borrowed view of an `IfcTaskTime`.
156#[derive(Debug, Clone, Copy)]
157pub struct TaskTime<'m> {
158    id: EntityId,
159    entity: &'m Entity,
160}
161
162impl<'m> TaskTime<'m> {
163    /// The entity id.
164    #[must_use]
165    pub fn id(&self) -> EntityId {
166        self.id
167    }
168
169    /// Whether the duration is working or elapsed time.
170    #[must_use]
171    pub fn duration_type(&self) -> Option<DurationType> {
172        match self.entity.attribute(time_slot::DURATION_TYPE)? {
173            Value::Enum(token) => DurationType::parse(token),
174            _ => None,
175        }
176    }
177
178    /// The planned duration, as an authored ISO 8601 duration.
179    #[must_use]
180    pub fn schedule_duration(&self) -> Option<&'m str> {
181        self.entity.text(time_slot::SCHEDULE_DURATION)
182    }
183
184    /// The planned start, as authored.
185    #[must_use]
186    pub fn schedule_start(&self) -> Option<&'m str> {
187        self.entity.text(time_slot::SCHEDULE_START)
188    }
189
190    /// The planned finish, as authored.
191    #[must_use]
192    pub fn schedule_finish(&self) -> Option<&'m str> {
193        self.entity.text(time_slot::SCHEDULE_FINISH)
194    }
195
196    /// The earliest start, as authored.
197    #[must_use]
198    pub fn early_start(&self) -> Option<&'m str> {
199        self.entity.text(time_slot::EARLY_START)
200    }
201
202    /// The latest finish, as authored.
203    #[must_use]
204    pub fn late_finish(&self) -> Option<&'m str> {
205        self.entity.text(time_slot::LATE_FINISH)
206    }
207
208    /// Free float, as an authored ISO 8601 duration.
209    #[must_use]
210    pub fn free_float(&self) -> Option<&'m str> {
211        self.entity.text(time_slot::FREE_FLOAT)
212    }
213
214    /// Total float, as an authored ISO 8601 duration.
215    #[must_use]
216    pub fn total_float(&self) -> Option<&'m str> {
217        self.entity.text(time_slot::TOTAL_FLOAT)
218    }
219
220    /// Whether the file marks this task as critical.
221    ///
222    /// Reported as authored: this crate does not compute a critical path,
223    /// because doing so needs the full sequence graph and a calendar, and a
224    /// computed answer that disagreed with the file would be indistinguishable
225    /// from a stated one.
226    #[must_use]
227    pub fn is_critical(&self) -> Option<bool> {
228        self.entity.attribute(time_slot::IS_CRITICAL)?.as_bool()
229    }
230
231    /// The actual start, as authored.
232    #[must_use]
233    pub fn actual_start(&self) -> Option<&'m str> {
234        self.entity.text(time_slot::ACTUAL_START)
235    }
236
237    /// The actual finish, as authored.
238    #[must_use]
239    pub fn actual_finish(&self) -> Option<&'m str> {
240        self.entity.text(time_slot::ACTUAL_FINISH)
241    }
242
243    /// The actual duration, as authored.
244    #[must_use]
245    pub fn actual_duration(&self) -> Option<&'m str> {
246        self.entity.text(time_slot::ACTUAL_DURATION)
247    }
248
249    /// Percent complete, if stated.
250    #[must_use]
251    pub fn completion(&self) -> Option<f64> {
252        self.entity
253            .attribute(time_slot::COMPLETION)?
254            .unwrap_typed()
255            .as_f64()
256    }
257}
258
259/// A borrowed view of an `IfcTask`.
260#[derive(Debug, Clone, Copy)]
261pub struct Task<'m> {
262    id: EntityId,
263    entity: &'m Entity,
264}
265
266impl<'m> Task<'m> {
267    /// Wrap an entity known to be an `IfcTask`.
268    #[must_use]
269    pub fn new(id: EntityId, entity: &'m Entity) -> Self {
270        Self { id, entity }
271    }
272
273    /// The entity id in the file.
274    #[must_use]
275    pub fn id(&self) -> EntityId {
276        self.id
277    }
278
279    /// The `GlobalId` string.
280    #[must_use]
281    pub fn global_id(&self) -> Option<&'m str> {
282        self.entity.text(task_slot::GLOBAL_ID)
283    }
284
285    /// The task name.
286    #[must_use]
287    pub fn name(&self) -> Option<&'m str> {
288        self.entity.text(task_slot::NAME)
289    }
290
291    /// The short description.
292    #[must_use]
293    pub fn description(&self) -> Option<&'m str> {
294        self.entity.text(task_slot::DESCRIPTION)
295    }
296
297    /// The user-facing identification code, e.g. a WBS number.
298    #[must_use]
299    pub fn identification(&self) -> Option<&'m str> {
300        self.entity.text(task_slot::IDENTIFICATION)
301    }
302
303    /// The long description.
304    #[must_use]
305    pub fn long_description(&self) -> Option<&'m str> {
306        self.entity.text(task_slot::LONG_DESCRIPTION)
307    }
308
309    /// The authored status string.
310    #[must_use]
311    pub fn status(&self) -> Option<&'m str> {
312        self.entity.text(task_slot::STATUS)
313    }
314
315    /// The work method.
316    #[must_use]
317    pub fn work_method(&self) -> Option<&'m str> {
318        self.entity.text(task_slot::WORK_METHOD)
319    }
320
321    /// Whether the task is a milestone.
322    ///
323    /// Required by the schema, so `None` means the file omitted a mandatory
324    /// field rather than "not a milestone".
325    #[must_use]
326    pub fn is_milestone(&self) -> Option<bool> {
327        self.entity.attribute(task_slot::IS_MILESTONE)?.as_bool()
328    }
329
330    /// The scheduling priority, if stated.
331    #[must_use]
332    pub fn priority(&self) -> Option<i64> {
333        self.entity.attribute(task_slot::PRIORITY)?.as_i64()
334    }
335
336    /// The predefined type token, without its dots.
337    #[must_use]
338    pub fn predefined_type(&self) -> Option<&'m str> {
339        match self.entity.attribute(task_slot::PREDEFINED_TYPE)? {
340            Value::Enum(token) => Some(token),
341            _ => None,
342        }
343    }
344
345    /// The id this task's `TaskTime` slot points at, if any.
346    #[must_use]
347    pub fn task_time_ref(&self) -> Option<EntityId> {
348        match self.entity.attribute(task_slot::TASK_TIME)? {
349            Value::Ref(id) => Some(*id),
350            _ => None,
351        }
352    }
353
354    /// Resolve this task's `IfcTaskTime`.
355    ///
356    /// Returns the view and any anomaly found while resolving it: a reference
357    /// to a non-`IfcTaskTime`, or a milestone that states a duration.
358    #[must_use]
359    pub fn time(&self, model: &'m Model) -> (Option<TaskTime<'m>>, Vec<TaskTimeAnomaly>) {
360        let mut anomalies = Vec::new();
361        let Some(target) = self.task_time_ref() else {
362            return (None, anomalies);
363        };
364        let Some(entity) = model.get(target) else {
365            return (None, anomalies);
366        };
367        if !entity.type_name.eq_ignore_ascii_case("IFCTASKTIME") {
368            anomalies.push(TaskTimeAnomaly::NotATaskTime {
369                task: self.id,
370                target,
371                found: entity.type_name.to_string(),
372            });
373            return (None, anomalies);
374        }
375
376        let time = TaskTime { id: target, entity };
377        // WR1: a milestone is an instant, so a schedule duration contradicts
378        // the flag. Report both facts; do not pick a winner.
379        if self.is_milestone() == Some(true) {
380            if let Some(duration) = time.schedule_duration() {
381                anomalies.push(TaskTimeAnomaly::MilestoneWithDuration {
382                    task: self.id,
383                    time: target,
384                    duration: duration.to_string(),
385                });
386            }
387        }
388        (Some(time), anomalies)
389    }
390}
391
392/// Every task in the model, in file order.
393#[must_use]
394pub fn tasks(model: &Model) -> Vec<Task<'_>> {
395    model
396        .of_type("IFCTASK")
397        .map(|(id, entity)| Task::new(id, entity))
398        .collect()
399}