ifc-schedule 0.2.1

IFC scheduling: IfcTask/IfcWorkSchedule, sequencing, 4D linkage.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
//! Transactional authoring for tasks, task times and sequence links.
//!
//! The crate could read a programme and not write one. These constructors
//! reuse the slot constants the readers in [`crate::task`] and
//! [`crate::sequence`] already declare, so an authored task is readable by
//! construction rather than by coincidence -- `IfcTask` carries thirteen
//! slots, seven of them inherited from `IfcRoot`, `IfcObject` and
//! `IfcProcess`, and a constructor that counted only the six declared
//! attributes would put Status where GlobalId belongs.
//!
//! # Releases (#202)
//!
//! The writers here take no model, so they cannot see the declared release.
//! They write the layout IFC4 ADD2 TC1 and IFC4X3 ADD2 share, with
//! `IfcRoot.OwnerHistory` unset, and are for those releases only. IFC2X3
//! requires `OwnerHistory` and lays several records out differently, so
//! each `IfcRoot` writer has a `*_with_owner_history` variant that binds the
//! model's declared release, takes a caller-supplied `IfcOwnerHistory` and
//! places every attribute by name from that release's table.
//!
//! Durations and timestamps are written as authored: ISO 8601 strings are
//! not parsed here, because a scheduling tool owns calendar semantics and
//! silently normalising them would lose the authored intent.

use ifc_model::{Entity, EntityId, Transaction, Value};

use crate::calendar::recurrence_slot;
use crate::calendar::{work_calendar_slot, work_time_slot};
use crate::error::ScheduleAuthoringError;
use crate::event::{event_slot, event_time_slot};
use crate::query::{assigns_slot, nests_slot};
use crate::schedule::work_control_slot as control_slot;
use crate::schedule::WorkControlKind;
use crate::sequence::lag_slot;
use crate::sequence::relation::slot as sequence_slot;
use crate::task::definition::task_slot;

/// Result of a schedule authoring call.
pub type ScheduleAuthoringResult<T> = Result<T, ScheduleAuthoringError>;

/// Authored fields for `IfcTask`.
#[derive(Debug, Clone, Copy, Default)]
pub struct TaskDraft<'a> {
    /// `IfcRoot.GlobalId`. Must be a valid IFC compressed GUID.
    pub global_id: &'a str,
    /// `IfcRoot.Name`, if given.
    pub name: Option<&'a str>,
    /// `IfcRoot.Description`, if given.
    pub description: Option<&'a str>,
    /// `IfcProcess.Identification`, if given.
    pub identification: Option<&'a str>,
    /// `IfcProcess.LongDescription`, if given.
    pub long_description: Option<&'a str>,
    /// `IfcTask.Status`, if given.
    pub status: Option<&'a str>,
    /// `IfcTask.WorkMethod`, if given.
    pub work_method: Option<&'a str>,
    /// `IfcTask.IsMilestone`. Required by the schema.
    pub is_milestone: bool,
    /// `IfcTask.Priority`, if given. An `IfcInteger` in `0..=100`.
    pub priority: Option<i64>,
    /// `IfcTask.TaskTime`, an `IfcTaskTime` reference, if given.
    pub task_time: Option<EntityId>,
    /// `IfcTask.PredefinedType`, if given.
    pub predefined_type: Option<&'a str>,
}

/// Stage an `IfcTask`.
///
/// Slots are filled by the reader's own constants, so the seven inherited
/// positions are reserved even when unset rather than counted by hand.
///
/// `OwnerHistory` is written `$`, which IFC4 and IFC4X3 allow, in their
/// shared layout. This writer takes no model, so it cannot see the declared
/// release: it is for IFC4 and IFC4X3 only. IFC2X3 requires
/// `OwnerHistory` and lays the record out differently; use
/// [`create_task_with_owner_history`] there, which binds the release (#202).
pub fn create_task(
    tx: &mut Transaction,
    draft: TaskDraft<'_>,
) -> ScheduleAuthoringResult<EntityId> {
    checks::task(&draft)?;
    let mut attributes = vec![Value::Null; task_slot::PREDEFINED_TYPE + 1];
    attributes[task_slot::GLOBAL_ID] = Value::Text(draft.global_id.into());
    attributes[task_slot::NAME] = optional_text(draft.name);
    attributes[task_slot::DESCRIPTION] = optional_text(draft.description);
    attributes[task_slot::IDENTIFICATION] = optional_text(draft.identification);
    attributes[task_slot::LONG_DESCRIPTION] = optional_text(draft.long_description);
    attributes[task_slot::STATUS] = optional_text(draft.status);
    attributes[task_slot::WORK_METHOD] = optional_text(draft.work_method);
    attributes[task_slot::IS_MILESTONE] = Value::Bool(draft.is_milestone);
    attributes[task_slot::PRIORITY] = draft.priority.map_or(Value::Null, Value::Integer);
    attributes[task_slot::TASK_TIME] = draft.task_time.map_or(Value::Null, Value::Ref);
    attributes[task_slot::PREDEFINED_TYPE] = draft
        .predefined_type
        .map_or(Value::Null, |t| Value::Enum(t.into()));
    Ok(tx.create(Entity::new("IFCTASK", attributes)))
}

pub(crate) fn optional_text(value: Option<&str>) -> Value {
    value.map_or(Value::Null, |text| Value::Text(text.into()))
}
/// Stage an `IfcRelSequence` linking a predecessor to a successor.
///
/// A task may not precede itself: a self-loop is an unsatisfiable
/// constraint that the traversal in [`crate::query`] would otherwise have
/// to detect as a cycle at read time.
///
/// `OwnerHistory` is written `$`, which IFC4 and IFC4X3 allow, in their
/// shared layout. This writer takes no model, so it cannot see the declared
/// release: it is for IFC4 and IFC4X3 only. IFC2X3 requires
/// `OwnerHistory` and lays the record out differently; use
/// [`create_sequence_with_owner_history`] there, which binds the release (#202).
pub fn create_sequence(
    tx: &mut Transaction,
    global_id: &str,
    predecessor: EntityId,
    successor: EntityId,
    sequence_type: Option<&str>,
    time_lag: Option<EntityId>,
) -> ScheduleAuthoringResult<EntityId> {
    checks::sequence(global_id, predecessor, successor)?;
    let mut attributes = vec![Value::Null; sequence_slot::SEQUENCE_TYPE + 2];
    attributes[0] = Value::Text(global_id.into());
    attributes[sequence_slot::RELATING] = Value::Ref(predecessor);
    attributes[sequence_slot::RELATED] = Value::Ref(successor);
    attributes[sequence_slot::TIME_LAG] = time_lag.map_or(Value::Null, Value::Ref);
    attributes[sequence_slot::SEQUENCE_TYPE] =
        sequence_type.map_or(Value::Null, |t| Value::Enum(t.into()));
    Ok(tx.create(Entity::new("IFCRELSEQUENCE", attributes)))
}

/// Authored fields for `IfcWorkPlan` and `IfcWorkSchedule`.
///
/// Both are `IfcWorkControl` subtypes with identical slots, so one draft
/// serves both and the kind picks the entity type. Timestamps and
/// durations are ISO 8601 strings written exactly as given, for the same
/// reason `IfcTaskTime` does not parse them.
#[derive(Debug, Clone, Copy, Default)]
pub struct WorkControlDraft<'a> {
    /// `IfcRoot.GlobalId`. Must be a valid IFC compressed GUID.
    pub global_id: &'a str,
    /// `IfcRoot.Name`, if given.
    pub name: Option<&'a str>,
    /// `IfcRoot.Description`, if given.
    pub description: Option<&'a str>,
    /// `IfcWorkControl.Identification`, if given.
    pub identification: Option<&'a str>,
    /// `IfcWorkControl.CreationDate`. Required by the schema.
    pub creation_date: &'a str,
    /// `IfcWorkControl.Purpose`, if given.
    pub purpose: Option<&'a str>,
    /// `IfcWorkControl.Duration`, an ISO 8601 duration, if given.
    pub duration: Option<&'a str>,
    /// `IfcWorkControl.TotalFloat`, an ISO 8601 duration, if given.
    pub total_float: Option<&'a str>,
    /// `IfcWorkControl.StartTime`. Required by the schema.
    pub start_time: &'a str,
    /// `IfcWorkControl.FinishTime`, if given.
    pub finish_time: Option<&'a str>,
    /// `IfcWorkControl.PredefinedType`, if given.
    pub predefined_type: Option<&'a str>,
}

/// Stage an `IfcWorkPlan` or `IfcWorkSchedule`.
///
/// `CreationDate` and `StartTime` are required by the schema, so they are
/// plain fields rather than options: a work control without them parses
/// but does not say when the work happens.
///
/// # Errors
///
/// Refuses a malformed GUID, and an empty required timestamp -- a blank
/// `StartTime` writes a schedule that validates and schedules nothing.
///
/// `OwnerHistory` is written `$`, which IFC4 and IFC4X3 allow, in their
/// shared layout. This writer takes no model, so it cannot see the declared
/// release: it is for IFC4 and IFC4X3 only. IFC2X3 requires
/// `OwnerHistory` and lays the record out differently; use
/// [`create_work_control_with_owner_history`] there, which binds the release (#202).
pub fn create_work_control(
    tx: &mut Transaction,
    kind: WorkControlKind,
    draft: WorkControlDraft<'_>,
) -> ScheduleAuthoringResult<EntityId> {
    let type_name = checks::work_control(kind, &draft)?;
    let mut attributes = vec![Value::Null; control_slot::PREDEFINED_TYPE + 1];
    attributes[control_slot::GLOBAL_ID] = Value::Text(draft.global_id.into());
    attributes[control_slot::NAME] = optional_text(draft.name);
    attributes[control_slot::DESCRIPTION] = optional_text(draft.description);
    attributes[control_slot::IDENTIFICATION] = optional_text(draft.identification);
    attributes[control_slot::CREATION_DATE] = Value::Text(draft.creation_date.into());
    attributes[control_slot::PURPOSE] = optional_text(draft.purpose);
    attributes[control_slot::DURATION] = optional_text(draft.duration);
    attributes[control_slot::TOTAL_FLOAT] = optional_text(draft.total_float);
    attributes[control_slot::START_TIME] = Value::Text(draft.start_time.into());
    attributes[control_slot::FINISH_TIME] = optional_text(draft.finish_time);
    attributes[control_slot::PREDEFINED_TYPE] = draft
        .predefined_type
        .map_or(Value::Null, |t| Value::Enum(t.into()));
    Ok(tx.create(Entity::new(type_name, attributes)))
}

/// Stage an `IfcRelAssignsToControl` binding tasks to a work control.
///
/// This is the link `tasks_of_schedule` reads back: a schedule with no
/// assignment owns nothing, however many tasks the file contains.
///
/// # Errors
///
/// Refuses a malformed GUID, and an empty task list -- an assignment
/// relating no objects parses and assigns nothing.
///
/// `OwnerHistory` is written `$`, which IFC4 and IFC4X3 allow, in their
/// shared layout. This writer takes no model, so it cannot see the declared
/// release: it is for IFC4 and IFC4X3 only. IFC2X3 requires
/// `OwnerHistory` and lays the record out differently; use
/// [`assign_tasks_to_control_with_owner_history`] there, which binds the release (#202).
pub fn assign_tasks_to_control(
    tx: &mut Transaction,
    global_id: &str,
    control: EntityId,
    tasks: &[EntityId],
) -> ScheduleAuthoringResult<EntityId> {
    checks::assignment(global_id, tasks)?;
    let mut attributes = vec![Value::Null; assigns_slot::RELATING + 1];
    attributes[assigns_slot::GLOBAL_ID] = Value::Text(global_id.into());
    attributes[assigns_slot::RELATED] =
        Value::List(tasks.iter().copied().map(Value::Ref).collect());
    attributes[assigns_slot::RELATING] = Value::Ref(control);
    Ok(tx.create(Entity::new("IFCRELASSIGNSTOCONTROL", attributes)))
}

/// Stage an `IfcRelNests` nesting child tasks under a parent.
///
/// Task breakdown structure: `IfcRelNests` is the ordered parent-child
/// link `subtasks_of` reads, not `IfcRelAggregates`, which nests physical
/// decomposition instead.
///
/// # Errors
///
/// Refuses a malformed GUID, an empty child list, and a parent that also
/// appears among its own children -- a self-nesting task is a cycle the
/// timeline walk cannot terminate on.
///
/// `OwnerHistory` is written `$`, which IFC4 and IFC4X3 allow, in their
/// shared layout. This writer takes no model, so it cannot see the declared
/// release: it is for IFC4 and IFC4X3 only. IFC2X3 requires
/// `OwnerHistory` and lays the record out differently; use
/// [`nest_tasks_with_owner_history`] there, which binds the release (#202).
pub fn nest_tasks(
    tx: &mut Transaction,
    global_id: &str,
    parent: EntityId,
    children: &[EntityId],
) -> ScheduleAuthoringResult<EntityId> {
    checks::nesting(global_id, parent, children)?;
    let mut attributes = vec![Value::Null; nests_slot::RELATED + 1];
    attributes[nests_slot::GLOBAL_ID] = Value::Text(global_id.into());
    attributes[nests_slot::RELATING] = Value::Ref(parent);
    attributes[nests_slot::RELATED] =
        Value::List(children.iter().copied().map(Value::Ref).collect());
    Ok(tx.create(Entity::new("IFCRELNESTS", attributes)))
}

/// Stage an `IfcWorkTime`.
///
/// One working or exception period inside a calendar. The recurrence
/// pattern is optional: a period with explicit start and finish dates and
/// no pattern is a single block, which is what a one-off shutdown is.
///
/// # Errors
///
/// Refuses an empty name when one is given, since a named period that
/// carries no name reads back as unnamed rather than as authored.
pub fn create_work_time(
    tx: &mut Transaction,
    name: Option<&str>,
    recurrence: Option<EntityId>,
    start: Option<&str>,
    finish: Option<&str>,
) -> ScheduleAuthoringResult<EntityId> {
    if name.is_some_and(|value| value.trim().is_empty()) {
        return Err(ScheduleAuthoringError::InvalidValue {
            entity: "IFCWORKTIME",
            attribute: "Name",
            expected: "a non-empty name when one is given",
        });
    }
    let mut attributes = vec![Value::Null; work_time_slot::FINISH + 1];
    attributes[work_time_slot::NAME] = optional_text(name);
    attributes[work_time_slot::RECURRENCE_PATTERN] = recurrence.map_or(Value::Null, Value::Ref);
    attributes[work_time_slot::START] = optional_text(start);
    attributes[work_time_slot::FINISH] = optional_text(finish);
    Ok(tx.create(Entity::new("IFCWORKTIME", attributes)))
}

/// Stage an `IfcWorkCalendar`.
///
/// Working times say when work happens; exception times carve holidays
/// out of them. Both are `IfcWorkTime` lists, so the two roles are
/// separate slots rather than a flag on the period.
///
/// # Errors
///
/// Refuses a malformed GUID, and a calendar with neither working nor
/// exception times -- it constrains nothing but reads as a real calendar.
///
/// `OwnerHistory` is written `$`, which IFC4 and IFC4X3 allow, in their
/// shared layout. This writer takes no model, so it cannot see the declared
/// release: it is for IFC4 and IFC4X3 only. IFC2X3 requires
/// `OwnerHistory` and lays the record out differently; use
/// [`create_work_calendar_with_owner_history`] there, which binds the release (#202).
pub fn create_work_calendar(
    tx: &mut Transaction,
    global_id: &str,
    name: Option<&str>,
    working_times: &[EntityId],
    exception_times: &[EntityId],
    predefined_type: Option<&str>,
) -> ScheduleAuthoringResult<EntityId> {
    checks::calendar(global_id, working_times, exception_times)?;
    let mut attributes = vec![Value::Null; work_calendar_slot::PREDEFINED_TYPE + 1];
    attributes[work_calendar_slot::GLOBAL_ID] = Value::Text(global_id.into());
    attributes[work_calendar_slot::NAME] = optional_text(name);
    attributes[work_calendar_slot::WORKING_TIMES] = reference_list(working_times);
    attributes[work_calendar_slot::EXCEPTION_TIMES] = reference_list(exception_times);
    attributes[work_calendar_slot::PREDEFINED_TYPE] =
        predefined_type.map_or(Value::Null, |t| Value::Enum(t.into()));
    Ok(tx.create(Entity::new("IFCWORKCALENDAR", attributes)))
}

/// A reference list, or `Null` when empty.
///
/// An empty IFC set is not the same as an absent one: writing `()` where
/// the file means "not stated" reads back as an authored empty set.
pub(super) fn reference_list(ids: &[EntityId]) -> Value {
    if ids.is_empty() {
        return Value::Null;
    }
    Value::List(ids.iter().copied().map(Value::Ref).collect())
}

/// Authored fields for `IfcEvent`.
///
/// Two schema WHERE rules are enforced here rather than left to a
/// downstream validator: both produce a file that parses cleanly and reads
/// back as a different event than the author meant.
#[derive(Debug, Clone, Copy, Default)]
pub struct EventDraft<'a> {
    /// `IfcRoot.GlobalId`. Must be a valid IFC compressed GUID.
    pub global_id: &'a str,
    /// `IfcRoot.Name`, if given.
    pub name: Option<&'a str>,
    /// `IfcObject.ObjectType`. Required when `predefined_type` is
    /// `USERDEFINED`.
    pub object_type: Option<&'a str>,
    /// `IfcProcess.Identification`, if given.
    pub identification: Option<&'a str>,
    /// `IfcProcess.LongDescription`, if given.
    pub long_description: Option<&'a str>,
    /// `IfcEvent.PredefinedType`, if given.
    pub predefined_type: Option<&'a str>,
    /// `IfcEvent.EventTriggerType`, if given.
    pub trigger_type: Option<&'a str>,
    /// `IfcEvent.UserDefinedEventTriggerType`. Required when `trigger_type`
    /// is `USERDEFINED`.
    pub user_defined_trigger_type: Option<&'a str>,
    /// `IfcEvent.EventOccurenceTime`, an `IfcEventTime` reference.
    pub occurence_time: Option<EntityId>,
}

/// Stage an `IfcEvent`.
///
/// Refuses a `USERDEFINED` discriminator whose accompanying label is
/// missing. The schema states both as WHERE rules, and a reader that meets
/// one has no way to recover what the author meant: the event reads back
/// as user-defined with nothing saying what it is.
///
/// `OwnerHistory` is written `$`, which IFC4 and IFC4X3 allow, in their
/// shared layout. This writer takes no model, so it cannot see the declared
/// release: it is for IFC4 and IFC4X3 only. IFC2X3 requires
/// `OwnerHistory` and lays the record out differently; use
/// [`create_event_with_owner_history`] there, which binds the release (#202).
pub fn create_event(
    tx: &mut Transaction,
    draft: EventDraft<'_>,
) -> ScheduleAuthoringResult<EntityId> {
    checks::event(&draft)?;
    let mut attributes = vec![Value::Null; event_slot::EVENT_OCCURENCE_TIME + 1];
    attributes[event_slot::GLOBAL_ID] = Value::Text(draft.global_id.into());
    attributes[event_slot::NAME] = optional_text(draft.name);
    attributes[event_slot::OBJECT_TYPE] = optional_text(draft.object_type);
    attributes[event_slot::IDENTIFICATION] = optional_text(draft.identification);
    attributes[event_slot::LONG_DESCRIPTION] = optional_text(draft.long_description);
    attributes[event_slot::PREDEFINED_TYPE] = optional_enum(draft.predefined_type);
    attributes[event_slot::EVENT_TRIGGER_TYPE] = optional_enum(draft.trigger_type);
    attributes[event_slot::USER_DEFINED_EVENT_TRIGGER_TYPE] =
        optional_text(draft.user_defined_trigger_type);
    attributes[event_slot::EVENT_OCCURENCE_TIME] =
        draft.occurence_time.map_or(Value::Null, Value::Ref);
    Ok(tx.create(Entity::new("IFCEVENT", attributes)))
}

/// Whether a discriminator names the USERDEFINED case.
pub(super) fn is_user_defined(value: Option<&str>) -> bool {
    value.is_some_and(|v| v.eq_ignore_ascii_case("USERDEFINED"))
}

/// Whether an accompanying label is absent or only whitespace.
///
/// A blank string satisfies EXISTS in the schema but carries no meaning, so
/// it is treated as absent here.
pub(super) fn blank(value: Option<&str>) -> bool {
    value.is_none_or(|v| v.trim().is_empty())
}

/// An optional enumeration value.
pub(super) fn optional_enum(value: Option<&str>) -> Value {
    value.map_or(Value::Null, |v| Value::Enum(v.into()))
}

/// Authored fields for `IfcEventTime`.
///
/// Dates are ISO 8601 strings written exactly as given, matching how
/// [`create_task_time`] treats its timestamps.
#[derive(Debug, Clone, Copy, Default)]
pub struct EventTimeDraft<'a> {
    /// `IfcSchedulingTime.Name`, if given.
    pub name: Option<&'a str>,
    /// `IfcEventTime.ActualDate`, if given.
    pub actual: Option<&'a str>,
    /// `IfcEventTime.EarlyDate`, if given.
    pub early: Option<&'a str>,
    /// `IfcEventTime.LateDate`, if given.
    pub late: Option<&'a str>,
    /// `IfcEventTime.ScheduleDate`, if given.
    pub schedule: Option<&'a str>,
}

/// Stage an `IfcEventTime`.
///
/// Every slot is optional in the schema, so an all-empty record is legal
/// and not refused here: it says "the dates are not yet known", which is a
/// meaningful state for a planned event.
pub fn create_event_time(
    tx: &mut Transaction,
    draft: EventTimeDraft<'_>,
) -> ScheduleAuthoringResult<EntityId> {
    let mut attributes = vec![Value::Null; event_time_slot::SCHEDULE_DATE + 1];
    attributes[event_time_slot::NAME] = optional_text(draft.name);
    attributes[event_time_slot::ACTUAL_DATE] = optional_text(draft.actual);
    attributes[event_time_slot::EARLY_DATE] = optional_text(draft.early);
    attributes[event_time_slot::LATE_DATE] = optional_text(draft.late);
    attributes[event_time_slot::SCHEDULE_DATE] = optional_text(draft.schedule);
    Ok(tx.create(Entity::new("IFCEVENTTIME", attributes)))
}

/// Stage an `IfcLagTime`.
///
/// `LagValue` and `DurationType` are both REQUIRED by the schema, unlike
/// every other scheduling-time field, so neither is an `Option` here: a lag
/// that states neither how long nor in what units is not a lag.
///
/// `lag_value` is an `IfcTimeOrRatioSelect`. A duration is an ISO 8601
/// string; a ratio is a plain number. The caller picks, because the two
/// mean different things and this crate will not guess.
///
/// `LagValue` is declared `IfcTimeOrRatioSelect = SELECT (IfcDuration,
/// IfcRatioMeasure)` in IFC4 and IFC4X3, so the value is written as the
/// typed parameter of the member it is (#201): a string as
/// `IFCDURATION('P5D')`, a number as `IFCRATIOMEASURE(0.5)` (an integer is
/// written as that REAL). A value already typed as one of those two members
/// is accepted as is.
pub fn create_lag_time(
    tx: &mut Transaction,
    name: Option<&str>,
    lag_value: Value,
    duration_type: &str,
) -> ScheduleAuthoringResult<EntityId> {
    if duration_type.trim().is_empty() {
        return Err(ScheduleAuthoringError::InvalidValue {
            entity: "IFCLAGTIME",
            attribute: "DurationType",
            expected: "a non-empty IfcTaskDurationEnum value",
        });
    }
    // (member, payload): the SELECT member the value is, as a bare value.
    let (member, payload) = match lag_value {
        Value::Typed { type_name, value } => (Some(type_name), *value),
        bare => (None, bare),
    };
    let is = |name: &str| member.as_ref().is_none_or(|m| m.eq_ignore_ascii_case(name));
    #[allow(clippy::cast_precision_loss)]
    let (member, payload) = match payload {
        Value::Text(text) if is("IFCDURATION") => ("IFCDURATION", Value::Text(text)),
        Value::Real(ratio) if is("IFCRATIOMEASURE") => ("IFCRATIOMEASURE", Value::Real(ratio)),
        Value::Integer(ratio) if is("IFCRATIOMEASURE") => {
            ("IFCRATIOMEASURE", Value::Real(ratio as f64))
        }
        _ => {
            return Err(ScheduleAuthoringError::InvalidValue {
                entity: "IFCLAGTIME",
                attribute: "LagValue",
                expected: "a duration string or a ratio number",
            })
        }
    };
    let lag_value = Value::Typed {
        type_name: member.into(),
        value: Box::new(payload),
    };
    let mut attributes = vec![Value::Null; lag_slot::DURATION_TYPE + 1];
    attributes[event_time_slot::NAME] = optional_text(name);
    attributes[lag_slot::LAG_VALUE] = lag_value;
    attributes[lag_slot::DURATION_TYPE] = Value::Enum(duration_type.into());
    Ok(tx.create(Entity::new("IFCLAGTIME", attributes)))
}

/// Authored fields for `IfcRecurrencePattern`.
///
/// Weekday and month components are 1-based in the schema: 1 = Monday
/// through 7 = Sunday, and 1 = January through 12 = December. Out-of-range
/// values are refused rather than written, because a reader has no way to
/// tell a 0-based authoring mistake from a deliberate value.
#[derive(Debug, Clone, Default)]
pub struct RecurrenceDraft<'a> {
    /// `RecurrenceType`. Required by the schema.
    pub recurrence_type: &'a str,
    /// `DayComponent`: days of the month, 1..=31.
    pub days: Vec<i64>,
    /// `WeekdayComponent`: 1 = Monday through 7 = Sunday.
    pub weekdays: Vec<i64>,
    /// `MonthComponent`: 1 = January through 12 = December.
    pub months: Vec<i64>,
    /// `Position`, for positional patterns. Negative counts from the end.
    pub position: Option<i64>,
    /// `Interval`: repeat every n periods. Must be positive.
    pub interval: Option<i64>,
    /// `Occurrences`: how many times it repeats. Must be positive.
    pub occurrences: Option<i64>,
}

/// Stage an `IfcRecurrencePattern`.
///
/// Refuses component values outside the schema's 1-based ranges, and a
/// non-positive interval or occurrence count: "every 0 weeks" and "repeats
/// -1 times" both parse and both describe nothing.
pub fn create_recurrence_pattern(
    tx: &mut Transaction,
    draft: &RecurrenceDraft<'_>,
) -> ScheduleAuthoringResult<EntityId> {
    if draft.recurrence_type.trim().is_empty() {
        return Err(ScheduleAuthoringError::InvalidValue {
            entity: "IFCRECURRENCEPATTERN",
            attribute: "RecurrenceType",
            expected: "a non-empty IfcRecurrenceTypeEnum value",
        });
    }
    check_range(&draft.days, 1, 31, "DayComponent")?;
    check_range(&draft.weekdays, 1, 7, "WeekdayComponent")?;
    check_range(&draft.months, 1, 12, "MonthComponent")?;
    check_positive(draft.interval, "Interval")?;
    check_positive(draft.occurrences, "Occurrences")?;
    let mut attributes = vec![Value::Null; recurrence_slot::OCCURRENCES + 1];
    attributes[recurrence_slot::RECURRENCE_TYPE] = Value::Enum(draft.recurrence_type.into());
    attributes[recurrence_slot::DAY_COMPONENT] = integer_list(&draft.days);
    attributes[recurrence_slot::WEEKDAY_COMPONENT] = integer_list(&draft.weekdays);
    attributes[recurrence_slot::MONTH_COMPONENT] = integer_list(&draft.months);
    attributes[recurrence_slot::POSITION] = draft.position.map_or(Value::Null, Value::Integer);
    attributes[recurrence_slot::INTERVAL] = draft.interval.map_or(Value::Null, Value::Integer);
    attributes[recurrence_slot::OCCURRENCES] =
        draft.occurrences.map_or(Value::Null, Value::Integer);
    Ok(tx.create(Entity::new("IFCRECURRENCEPATTERN", attributes)))
}

/// Refuse component values outside the schema's inclusive range.
fn check_range(
    values: &[i64],
    low: i64,
    high: i64,
    attribute: &'static str,
) -> ScheduleAuthoringResult<()> {
    if values.iter().any(|v| !(low..=high).contains(v)) {
        return Err(ScheduleAuthoringError::InvalidValue {
            entity: "IFCRECURRENCEPATTERN",
            attribute,
            expected: "components within the schema's 1-based range",
        });
    }
    Ok(())
}

/// Refuse a stated count that is zero or negative.
fn check_positive(value: Option<i64>, attribute: &'static str) -> ScheduleAuthoringResult<()> {
    if value.is_some_and(|v| v <= 0) {
        return Err(ScheduleAuthoringError::InvalidValue {
            entity: "IFCRECURRENCEPATTERN",
            attribute,
            expected: "a positive count",
        });
    }
    Ok(())
}

/// An omitted list stays `Null` rather than becoming an empty aggregate.
fn integer_list(values: &[i64]) -> Value {
    if values.is_empty() {
        return Value::Null;
    }
    Value::List(values.iter().copied().map(Value::Integer).collect())
}

mod checks;
mod owned;
mod procedure;
mod timing;

pub use owned::{
    assign_tasks_to_control_with_owner_history, create_event_with_owner_history,
    create_procedure_with_owner_history, create_sequence_with_owner_history,
    create_task_with_owner_history, create_work_calendar_with_owner_history,
    create_work_control_with_owner_history, nest_tasks_with_owner_history,
};
pub use procedure::{create_procedure, ProcedureDraft};
pub use timing::{create_task_time, create_task_time_recurring, create_time_period, TaskTimeDraft};