Skip to main content

ifc_schedule/task/
definition.rs

1//! `IfcTask` and `IfcTaskTime`.
2//!
3//! # Read by name in the declared release (#212)
4//!
5//! `IfcTask` is `IfcProcess` -> `IfcObject` -> `IfcRoot`. From the EXPRESS
6//! sources, IFC4 ADD2 TC1 and IFC4X3 ADD2 declare thirteen attributes and
7//! IFC2X3 TC1 ten:
8//!
9//! ```text
10//! IFC4, IFC4X3  0 GlobalId  1 OwnerHistory  2 Name  3 Description
11//!               4 ObjectType  5 Identification  6 LongDescription
12//!               7 Status  8 WorkMethod  9 IsMilestone  10 Priority
13//!               11 TaskTime  12 PredefinedType
14//! IFC2X3        0 GlobalId  1 OwnerHistory  2 Name  3 Description
15//!               4 ObjectType  5 TaskId  6 Status  7 WorkMethod
16//!               8 IsMilestone  9 Priority
17//! ```
18//!
19//! The IFC4 positions read an IFC2X3 task's status as its long description
20//! and its priority as the milestone flag. Every accessor here looks its
21//! attribute up by name in the model's declared release instead;
22//! `Identification` is IFC2X3's `TaskId`, which IFC4 promoted to
23//! `IfcProcess`. What IFC2X3 does not declare (`LongDescription`,
24//! `TaskTime`, `PredefinedType`, and `IfcTaskTime` itself) reads as `None`.
25//!
26//! ```text
27//! IfcTaskTime
28//! 0 Name                    1 DataOrigin           2 UserDefinedDataOrigin
29//! 3 DurationType            4 ScheduleDuration     5 ScheduleStart
30//! 6 ScheduleFinish          7 EarlyStart           8 EarlyFinish
31//! 9 LateStart              10 LateFinish          11 FreeFloat
32//! 12 TotalFloat            13 IsCritical          14 StatusTime
33//! 15 ActualDuration        16 ActualStart         17 ActualFinish
34//! 18 RemainingTime         19 Completion
35//! ```
36//!
37//! IFC4 and IFC4X3 subtype it as `IfcTaskTimeRecurring`, which adds
38//! `20 Recurrence : IfcRecurrencePattern`. `Task::time` accepts any subtype
39//! the release declares (#235) and reads it with its own attribute list.
40//! IFC2X3 times a task with an `IfcScheduleTimeControl` instead; see
41//! `Task::schedule_time_controls`.
42//!
43//! # A milestone has no duration, and that is a rule
44//!
45//! `IfcTaskTime` carries WHERE rule `WR1`:
46//!
47//! ```text
48//! WR1 : (NOT(EXISTS(SELF\IfcTaskTime.ScheduleDuration))) OR
49//!       (NOT(EXISTS(SELF\IfcTaskTime.ScheduleStart))) OR
50//!       ... task is not a milestone
51//! ```
52//!
53//! Practically: a task flagged `IsMilestone = .T.` states an instant, not a
54//! span, so a stated schedule duration contradicts the flag. This module
55//! reports that contradiction rather than choosing which field to believe.
56
57use ifc_model::{Entity, EntityId, Model};
58
59use crate::calendar::definition::read_recurrence;
60use crate::calendar::Recurrence;
61use crate::error::ScheduleReadError;
62use crate::release::ReadRelease;
63use crate::SchemaVersion;
64
65const TASK: &str = "IFCTASK";
66const TASK_TIME: &str = "IFCTASKTIME";
67const TASK_TIME_RECURRING: &str = "IFCTASKTIMERECURRING";
68
69/// `IfcTask` slots in the layout IFC4 and IFC4X3 share, which the
70/// modelless `create_task` writes. The reader goes by name.
71pub(crate) mod task_slot {
72    /// `GlobalId` (from `IfcRoot`).
73    pub const GLOBAL_ID: usize = 0;
74    /// `Name` (from `IfcRoot`).
75    pub const NAME: usize = 2;
76    /// `Description` (from `IfcRoot`).
77    pub const DESCRIPTION: usize = 3;
78    /// `Identification` (from `IfcProcess`).
79    pub const IDENTIFICATION: usize = 5;
80    /// `LongDescription` (from `IfcProcess`).
81    pub const LONG_DESCRIPTION: usize = 6;
82    /// `Status`.
83    pub const STATUS: usize = 7;
84    /// `WorkMethod`.
85    pub const WORK_METHOD: usize = 8;
86    /// `IsMilestone`, required.
87    pub const IS_MILESTONE: usize = 9;
88    /// `Priority`.
89    pub const PRIORITY: usize = 10;
90    /// `TaskTime`.
91    pub const TASK_TIME: usize = 11;
92    /// `PredefinedType`.
93    pub const PREDEFINED_TYPE: usize = 12;
94}
95
96/// `IfcTaskTime` slots, the same in IFC4 and IFC4X3 (IFC2X3 has no
97/// `IfcTaskTime`). The writer lays its record out with them; the reader goes
98/// by name.
99pub(crate) mod time_slot {
100    /// `DurationType`, `.WORKTIME.` or `.ELAPSEDTIME.`.
101    pub const DURATION_TYPE: usize = 3;
102    /// `ScheduleDuration`.
103    pub const SCHEDULE_DURATION: usize = 4;
104    /// `ScheduleStart`.
105    pub const SCHEDULE_START: usize = 5;
106    /// `ScheduleFinish`.
107    pub const SCHEDULE_FINISH: usize = 6;
108    /// `IsCritical`.
109    pub const IS_CRITICAL: usize = 13;
110    /// `ActualStart`.
111    pub const ACTUAL_START: usize = 16;
112    /// `ActualFinish`.
113    pub const ACTUAL_FINISH: usize = 17;
114    /// `Completion`, a percentage.
115    pub const COMPLETION: usize = 19;
116}
117
118/// Whether a duration counts working time or elapsed time.
119///
120/// `IfcTaskDurationEnum`. The distinction matters: two days of work time may
121/// span four calendar days across a weekend, and a caller converting one to
122/// the other needs the calendar.
123#[derive(Debug, Clone, Copy, PartialEq, Eq)]
124#[non_exhaustive]
125pub enum DurationType {
126    /// `.ELAPSEDTIME.`: calendar time, weekends included.
127    ElapsedTime,
128    /// `.WORKTIME.`: working time as defined by a calendar.
129    WorkTime,
130    /// `.NOTDEFINED.`
131    NotDefined,
132}
133
134impl DurationType {
135    pub(crate) fn parse(token: &str) -> Option<Self> {
136        Some(match token {
137            "ELAPSEDTIME" => Self::ElapsedTime,
138            "WORKTIME" => Self::WorkTime,
139            "NOTDEFINED" => Self::NotDefined,
140            _ => return None,
141        })
142    }
143}
144
145/// A contradiction between a task and its stated time.
146#[derive(Debug, Clone, PartialEq, Eq)]
147#[non_exhaustive]
148pub enum TaskTimeAnomaly {
149    /// The task is a milestone but its time states a schedule duration.
150    ///
151    /// `IfcTaskTime` `WR1`. A milestone is an instant; a duration contradicts
152    /// that, and neither field is authoritative over the other.
153    MilestoneWithDuration {
154        /// The task.
155        task: EntityId,
156        /// Its `IfcTaskTime`.
157        time: EntityId,
158        /// The duration the file states anyway.
159        duration: String,
160    },
161    /// `TaskTime` points at an entity that is not an `IfcTaskTime` or one
162    /// of its subtypes in the task's release.
163    NotATaskTime {
164        /// The task.
165        task: EntityId,
166        /// What it points at.
167        target: EntityId,
168        /// The type actually found.
169        found: String,
170    },
171    /// An IFC2X3 `IfcRelAssignsTasks.TimeForTask` points at an entity that
172    /// is not an `IfcScheduleTimeControl` (#235).
173    NotAScheduleTimeControl {
174        /// The task.
175        task: EntityId,
176        /// The `IfcRelAssignsTasks` that assigns it.
177        assignment: EntityId,
178        /// What `TimeForTask` points at.
179        target: EntityId,
180        /// The type actually found.
181        found: String,
182    },
183}
184
185/// A borrowed view of an `IfcTaskTime`.
186#[derive(Debug, Clone, Copy)]
187pub struct TaskTime<'m> {
188    id: EntityId,
189    entity: &'m Entity,
190    release: ReadRelease,
191}
192
193impl<'m> TaskTime<'m> {
194    /// The entity id.
195    #[must_use]
196    pub fn id(&self) -> EntityId {
197        self.id
198    }
199
200    /// The record's own type, so an `IfcTaskTimeRecurring` is read with
201    /// its own attribute list, `Recurrence` included (#235).
202    fn type_name(&self) -> &'m str {
203        &self.entity.type_name
204    }
205
206    /// Whether this is an `IfcTaskTimeRecurring` (IFC4, IFC4X3) (#235).
207    #[must_use]
208    pub fn is_recurring(&self) -> bool {
209        self.release.is_a(self.type_name(), TASK_TIME_RECURRING)
210    }
211
212    /// The `IfcTaskTimeRecurring.Recurrence` reference; `None` for a plain
213    /// `IfcTaskTime` (#235).
214    #[must_use]
215    pub fn recurrence_ref(&self) -> Option<EntityId> {
216        self.release
217            .reference(self.type_name(), self.entity, "Recurrence")
218    }
219
220    /// The recurrence pattern of an `IfcTaskTimeRecurring`, read by name
221    /// in the task's release (#235). `None` for a plain `IfcTaskTime`, or
222    /// when `Recurrence` (required by the schema) is missing or does not
223    /// reference an `IfcRecurrencePattern`.
224    #[must_use]
225    pub fn recurrence(&self, model: &Model) -> Option<Recurrence> {
226        read_recurrence(self.release, model, self.recurrence_ref()?)
227    }
228
229    /// Whether the duration is working or elapsed time.
230    #[must_use]
231    pub fn duration_type(&self) -> Option<DurationType> {
232        DurationType::parse(
233            self.release
234                .token(self.type_name(), self.entity, "DurationType")?,
235        )
236    }
237
238    /// The planned duration, as an authored ISO 8601 duration.
239    #[must_use]
240    pub fn schedule_duration(&self) -> Option<&'m str> {
241        self.release
242            .text(self.type_name(), self.entity, "ScheduleDuration")
243    }
244
245    /// The planned start, as authored.
246    #[must_use]
247    pub fn schedule_start(&self) -> Option<&'m str> {
248        self.release
249            .text(self.type_name(), self.entity, "ScheduleStart")
250    }
251
252    /// The planned finish, as authored.
253    #[must_use]
254    pub fn schedule_finish(&self) -> Option<&'m str> {
255        self.release
256            .text(self.type_name(), self.entity, "ScheduleFinish")
257    }
258
259    /// The earliest start, as authored.
260    #[must_use]
261    pub fn early_start(&self) -> Option<&'m str> {
262        self.release
263            .text(self.type_name(), self.entity, "EarlyStart")
264    }
265
266    /// The latest finish, as authored.
267    #[must_use]
268    pub fn late_finish(&self) -> Option<&'m str> {
269        self.release
270            .text(self.type_name(), self.entity, "LateFinish")
271    }
272
273    /// Free float, as an authored ISO 8601 duration.
274    #[must_use]
275    pub fn free_float(&self) -> Option<&'m str> {
276        self.release
277            .text(self.type_name(), self.entity, "FreeFloat")
278    }
279
280    /// Total float, as an authored ISO 8601 duration.
281    #[must_use]
282    pub fn total_float(&self) -> Option<&'m str> {
283        self.release
284            .text(self.type_name(), self.entity, "TotalFloat")
285    }
286
287    /// Whether the file marks this task as critical.
288    ///
289    /// Reported as authored: this crate does not compute a critical path,
290    /// because doing so needs the full sequence graph and a calendar, and a
291    /// computed answer that disagreed with the file would be indistinguishable
292    /// from a stated one.
293    #[must_use]
294    pub fn is_critical(&self) -> Option<bool> {
295        self.release
296            .value(self.type_name(), self.entity, "IsCritical")?
297            .as_bool()
298    }
299
300    /// The actual start, as authored.
301    #[must_use]
302    pub fn actual_start(&self) -> Option<&'m str> {
303        self.release
304            .text(self.type_name(), self.entity, "ActualStart")
305    }
306
307    /// The actual finish, as authored.
308    #[must_use]
309    pub fn actual_finish(&self) -> Option<&'m str> {
310        self.release
311            .text(self.type_name(), self.entity, "ActualFinish")
312    }
313
314    /// The actual duration, as authored.
315    #[must_use]
316    pub fn actual_duration(&self) -> Option<&'m str> {
317        self.release
318            .text(self.type_name(), self.entity, "ActualDuration")
319    }
320
321    /// Percent complete, if stated.
322    #[must_use]
323    pub fn completion(&self) -> Option<f64> {
324        self.release
325            .value(self.type_name(), self.entity, "Completion")?
326            .unwrap_typed()
327            .as_f64()
328    }
329}
330
331/// A borrowed view of an `IfcTask`, read against one release.
332#[derive(Debug, Clone, Copy)]
333pub struct Task<'m> {
334    id: EntityId,
335    entity: &'m Entity,
336    release: ReadRelease,
337}
338
339impl<'m> Task<'m> {
340    /// Wrap an entity known to be an `IfcTask`, read against `release`.
341    ///
342    /// [`tasks`] binds the model's declared release; use this when the
343    /// release is known some other way.
344    ///
345    /// # Errors
346    ///
347    /// [`ScheduleReadError::UnsupportedSchema`] for a release the readers
348    /// are not verified against (IFC4X1, IFC4X2).
349    pub fn new(
350        id: EntityId,
351        entity: &'m Entity,
352        release: SchemaVersion,
353    ) -> Result<Self, ScheduleReadError> {
354        Ok(Self::bound(id, entity, ReadRelease::of_version(release)?))
355    }
356
357    pub(crate) const fn bound(id: EntityId, entity: &'m Entity, release: ReadRelease) -> Self {
358        Self {
359            id,
360            entity,
361            release,
362        }
363    }
364
365    /// The release this task is read against.
366    #[must_use]
367    pub fn release(&self) -> SchemaVersion {
368        self.release.version()
369    }
370
371    pub(crate) const fn read_release(&self) -> ReadRelease {
372        self.release
373    }
374
375    fn text(&self, attribute: &'static str) -> Option<&'m str> {
376        self.release.text(TASK, self.entity, attribute)
377    }
378
379    /// The entity id in the file.
380    #[must_use]
381    pub fn id(&self) -> EntityId {
382        self.id
383    }
384
385    /// The `GlobalId` string.
386    #[must_use]
387    pub fn global_id(&self) -> Option<&'m str> {
388        self.text("GlobalId")
389    }
390
391    /// The task name.
392    #[must_use]
393    pub fn name(&self) -> Option<&'m str> {
394        self.text("Name")
395    }
396
397    /// The short description.
398    #[must_use]
399    pub fn description(&self) -> Option<&'m str> {
400        self.text("Description")
401    }
402
403    /// The user-facing identification code, e.g. a WBS number: IFC4's
404    /// `Identification`, IFC2X3's `TaskId`.
405    #[must_use]
406    pub fn identification(&self) -> Option<&'m str> {
407        self.text("Identification")
408    }
409
410    /// The long description. `None` in IFC2X3, which declares none.
411    #[must_use]
412    pub fn long_description(&self) -> Option<&'m str> {
413        self.text("LongDescription")
414    }
415
416    /// The authored status string.
417    #[must_use]
418    pub fn status(&self) -> Option<&'m str> {
419        self.text("Status")
420    }
421
422    /// The work method.
423    #[must_use]
424    pub fn work_method(&self) -> Option<&'m str> {
425        self.text("WorkMethod")
426    }
427
428    /// Whether the task is a milestone.
429    ///
430    /// Required by the schema, so `None` means the file omitted a mandatory
431    /// field rather than "not a milestone".
432    #[must_use]
433    pub fn is_milestone(&self) -> Option<bool> {
434        self.release
435            .value(TASK, self.entity, "IsMilestone")?
436            .as_bool()
437    }
438
439    /// The scheduling priority, if stated.
440    #[must_use]
441    pub fn priority(&self) -> Option<i64> {
442        self.release.value(TASK, self.entity, "Priority")?.as_i64()
443    }
444
445    /// The predefined type token, without its dots. `None` in IFC2X3,
446    /// which declares none.
447    #[must_use]
448    pub fn predefined_type(&self) -> Option<&'m str> {
449        self.release.token(TASK, self.entity, "PredefinedType")
450    }
451
452    /// The id this task's `TaskTime` points at, if any. `None` in IFC2X3,
453    /// which declares no `IfcTaskTime`.
454    #[must_use]
455    pub fn task_time_ref(&self) -> Option<EntityId> {
456        self.release.reference(TASK, self.entity, "TaskTime")
457    }
458
459    /// Resolve this task's `IfcTaskTime`, or its `IfcTaskTimeRecurring`
460    /// subtype (#235); see [`TaskTime::recurrence`].
461    ///
462    /// Returns the view and any anomaly found while resolving it: a reference
463    /// to a non-`IfcTaskTime`, or a milestone that states a duration.
464    /// IFC2X3 declares no `TaskTime`; its tasks are timed by
465    /// [`Self::schedule_time_controls`].
466    #[must_use]
467    pub fn time(&self, model: &'m Model) -> (Option<TaskTime<'m>>, Vec<TaskTimeAnomaly>) {
468        let mut anomalies = Vec::new();
469        let Some(target) = self.task_time_ref() else {
470            return (None, anomalies);
471        };
472        let Some(entity) = model.get(target) else {
473            return (None, anomalies);
474        };
475        if !self.release.is_a(&entity.type_name, TASK_TIME) {
476            anomalies.push(TaskTimeAnomaly::NotATaskTime {
477                task: self.id,
478                target,
479                found: entity.type_name.to_string(),
480            });
481            return (None, anomalies);
482        }
483
484        let time = TaskTime {
485            id: target,
486            entity,
487            release: self.release,
488        };
489        // WR1: a milestone is an instant, so a schedule duration contradicts
490        // the flag. Report both facts; do not pick a winner.
491        if self.is_milestone() == Some(true) {
492            if let Some(duration) = time.schedule_duration() {
493                anomalies.push(TaskTimeAnomaly::MilestoneWithDuration {
494                    task: self.id,
495                    time: target,
496                    duration: duration.to_string(),
497                });
498            }
499        }
500        (Some(time), anomalies)
501    }
502}
503
504/// Every task in the model, in file order, read against the model's
505/// declared release.
506///
507/// # Errors
508///
509/// [`ScheduleReadError::UnsupportedSchema`] or
510/// [`ScheduleReadError::MultipleSchemas`] for a header the readers cannot
511/// bind; a header with no schema reads as IFC4.
512pub fn tasks(model: &Model) -> Result<Vec<Task<'_>>, ScheduleReadError> {
513    let release = ReadRelease::of(model)?;
514    Ok(model
515        .of_type(TASK)
516        .map(|(id, entity)| Task::bound(id, entity, release))
517        .collect())
518}