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}