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}