scriptplan 0.9.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. scriptplan/__init__.py +22 -0
  2. scriptplan/cli/__init__.py +7 -0
  3. scriptplan/cli/main.py +546 -0
  4. scriptplan/core/__init__.py +0 -0
  5. scriptplan/core/account.py +125 -0
  6. scriptplan/core/allocation.py +69 -0
  7. scriptplan/core/booking.py +39 -0
  8. scriptplan/core/journal.py +377 -0
  9. scriptplan/core/leave.py +14 -0
  10. scriptplan/core/limits.py +354 -0
  11. scriptplan/core/project.py +924 -0
  12. scriptplan/core/property.py +1290 -0
  13. scriptplan/core/resource.py +198 -0
  14. scriptplan/core/resource_scenario.py +711 -0
  15. scriptplan/core/scenario.py +5 -0
  16. scriptplan/core/scenario_data.py +39 -0
  17. scriptplan/core/shift.py +71 -0
  18. scriptplan/core/task.py +77 -0
  19. scriptplan/core/task_scenario.py +1515 -0
  20. scriptplan/core/timesheet.py +457 -0
  21. scriptplan/core/working_hours.py +231 -0
  22. scriptplan/parser/__init__.py +0 -0
  23. scriptplan/parser/macro_processor.py +264 -0
  24. scriptplan/parser/tjp.lark +412 -0
  25. scriptplan/parser/tjp_parser.py +1904 -0
  26. scriptplan/py.typed +0 -0
  27. scriptplan/report/__init__.py +75 -0
  28. scriptplan/report/html_generator.py +477 -0
  29. scriptplan/report/report.py +466 -0
  30. scriptplan/report/report_base.py +397 -0
  31. scriptplan/report/report_context.py +248 -0
  32. scriptplan/report/resource_report.py +341 -0
  33. scriptplan/report/table_report.py +693 -0
  34. scriptplan/report/task_report.py +362 -0
  35. scriptplan/report/text_report.py +172 -0
  36. scriptplan/scheduler/__init__.py +0 -0
  37. scriptplan/scheduler/batch_processor.py +238 -0
  38. scriptplan/scheduler/scoreboard.py +120 -0
  39. scriptplan/utils/__init__.py +0 -0
  40. scriptplan/utils/data_cache.py +46 -0
  41. scriptplan/utils/logger.py +243 -0
  42. scriptplan/utils/message_handler.py +515 -0
  43. scriptplan/utils/time.py +195 -0
  44. scriptplan-0.9.0.dist-info/METADATA +161 -0
  45. scriptplan-0.9.0.dist-info/RECORD +49 -0
  46. scriptplan-0.9.0.dist-info/WHEEL +5 -0
  47. scriptplan-0.9.0.dist-info/entry_points.txt +2 -0
  48. scriptplan-0.9.0.dist-info/licenses/LICENSE +201 -0
  49. scriptplan-0.9.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1515 @@
1
+ from scriptplan.core.scenario_data import ScenarioData
2
+ from scriptplan.core.property import AttributeBase
3
+
4
+ # Default working hours: 9am-5pm (0-indexed: hours 9-16 are working)
5
+ DEFAULT_WORK_START_HOUR = 9
6
+ DEFAULT_WORK_END_HOUR = 17 # 5pm, so hours 9,10,11,12,13,14,15,16 are working (8 hours)
7
+
8
+
9
+ class TaskScenario(ScenarioData):
10
+ def __init__(self, task, scenarioIdx, attributes):
11
+ super().__init__(task, scenarioIdx, attributes)
12
+ self.isRunAway = False
13
+ self.hasDurationSpec = False
14
+ self.scheduled = False
15
+ self.currentSlotIdx = None
16
+
17
+ # Ensure required attributes exist
18
+ required_attrs = [
19
+ 'allocate', 'assignedresources', 'booking', 'charge', 'chargeset', 'complete',
20
+ 'competitors', 'criticalness', 'depends', 'duration',
21
+ 'effort', 'effortdone', 'effortleft', 'end', 'forward', 'gauge', 'length',
22
+ 'maxend', 'maxstart', 'minend', 'minstart', 'milestone', 'pathcriticalness',
23
+ 'precedes', 'priority', 'projectionmode', 'responsible',
24
+ 'scheduled', 'shifts', 'start', 'status'
25
+ ]
26
+
27
+ for attr in required_attrs:
28
+ try:
29
+ _ = self.property[(attr, self.scenarioIdx)]
30
+ except ValueError:
31
+ pass
32
+
33
+ if not self.property.parent:
34
+ mode = AttributeBase.mode()
35
+ AttributeBase.setMode(1)
36
+
37
+ proj_projection = self.project.scenario(self.scenarioIdx).get('projection') if hasattr(self.project, 'scenario') else None
38
+ if proj_projection:
39
+ self.property[( 'projectionmode', self.scenarioIdx )] = proj_projection
40
+
41
+ AttributeBase.setMode(mode)
42
+
43
+ def prepareScheduling(self):
44
+ """
45
+ Reset all scheduling related data prior to scheduling.
46
+ Called once per scenario before scheduling begins.
47
+ """
48
+ self.isRunAway = False
49
+ self.currentSlotIdx = None
50
+ self.doneDuration = 0
51
+ self.doneLength = 0
52
+ self.doneEffort = 0.0
53
+ self.scheduled = False
54
+ self._selectedResources = None # Reset alternative resource selection
55
+
56
+ # Track exact start time within a slot (for mid-slot dependency starts)
57
+ # This is the number of seconds into the slot where we should start booking
58
+ self.slotStartOffset = 0.0
59
+
60
+ # Reset the counters of all limits of this task (not parent tasks).
61
+ # This is critical - limits track usage per period and must be reset
62
+ # before each scheduling run to avoid carrying over counts from
63
+ # previous scenario scheduling.
64
+ limits = self.property.get('limits', self.scenarioIdx)
65
+ if limits:
66
+ limits.reset()
67
+
68
+ def getAllDependencies(self):
69
+ """
70
+ Get all dependencies including inherited ones from parent containers.
71
+
72
+ In TaskJuggler, child tasks inherit dependencies from their parent
73
+ containers. For example, if a container 'software' depends on 'spec',
74
+ all children of 'software' (database, gui, backend) also depend on 'spec'.
75
+ """
76
+ all_deps = []
77
+
78
+ # Get own dependencies
79
+ own_deps = self.property.get('depends', self.scenarioIdx) or []
80
+ all_deps.extend(own_deps)
81
+
82
+ # Get parent dependencies (recursively up the tree)
83
+ parent = self.property.parent
84
+ while parent:
85
+ parent_deps = parent.get('depends', self.scenarioIdx) or []
86
+ all_deps.extend(parent_deps)
87
+ parent = parent.parent
88
+
89
+ return all_deps
90
+
91
+ def readyForScheduling(self):
92
+ """
93
+ Check if task is ready for scheduling.
94
+
95
+ For ASAP (forward) scheduling: check if all dependencies are scheduled.
96
+ For ALAP (backward) scheduling: check if all successors (tasks that depend on this)
97
+ are scheduled, so we can use their start times as our end constraint.
98
+ """
99
+ forward = self.property.get('forward', self.scenarioIdx)
100
+
101
+ if forward is False:
102
+ # ALAP scheduling - check successors
103
+ # A task is ready when all tasks that depend on it are scheduled
104
+ # (so we know when this task must end)
105
+ return self._alapReadyForScheduling()
106
+ else:
107
+ # ASAP scheduling - check dependencies
108
+ return self._asapReadyForScheduling()
109
+
110
+ def _asapReadyForScheduling(self):
111
+ """Check if all dependencies are scheduled (for ASAP mode)."""
112
+ for dep in self.getAllDependencies():
113
+ if isinstance(dep, dict):
114
+ t = dep.get('task')
115
+ elif hasattr(dep, 'task'):
116
+ t = dep.task
117
+ else:
118
+ t = dep
119
+
120
+ if t and not t.get('scheduled', self.scenarioIdx):
121
+ return False
122
+
123
+ return True
124
+
125
+ def _alapReadyForScheduling(self):
126
+ """
127
+ Check if task is ready for ALAP scheduling.
128
+
129
+ For ALAP, a task is ready when:
130
+ 1. It has an explicit end date (anchor), OR
131
+ 2. All tasks that depend on this task (successors) are scheduled
132
+ (so we can derive our end from their start), OR
133
+ 3. For onstart dependencies: the predecessor must be scheduled first
134
+ (so we can derive our end from their start)
135
+ """
136
+ # If task has explicit end date, it's an anchor - always ready
137
+ if self.property.get('end', self.scenarioIdx):
138
+ return True
139
+
140
+ # Check onstart dependencies - we need predecessor scheduled to know their start
141
+ # For ALAP with `depends X { onstart }`, this task's END depends on X's START
142
+ for dep in self.getAllDependencies():
143
+ if isinstance(dep, dict):
144
+ onstart = dep.get('onstart', False)
145
+ pred = dep.get('task')
146
+ elif hasattr(dep, 'task'):
147
+ onstart = getattr(dep, 'onstart', False)
148
+ pred = dep.task
149
+ else:
150
+ onstart = False
151
+ pred = dep
152
+
153
+ if onstart and pred and not pred.get('scheduled', self.scenarioIdx):
154
+ # Predecessor not scheduled yet - we can't derive our end
155
+ return False
156
+
157
+ # Check if all successors are scheduled (for finish-to-start deps)
158
+ successors = self._getSuccessors()
159
+ if not successors:
160
+ # No successors and no explicit end - use project end as default
161
+ # (unless we have onstart deps, which we checked above)
162
+ return True
163
+
164
+ for successor in successors:
165
+ if not successor.get('scheduled', self.scenarioIdx):
166
+ return False
167
+
168
+ return True
169
+
170
+ def _getSuccessors(self):
171
+ """
172
+ Get all tasks that depend on this task (successors).
173
+
174
+ These are tasks T where T's dependencies include this task.
175
+ """
176
+ successors = []
177
+ for task in self.project.tasks:
178
+ if not task.leaf():
179
+ continue
180
+ deps = task.get('depends', self.scenarioIdx) or []
181
+ for dep in deps:
182
+ if isinstance(dep, dict):
183
+ pred = dep.get('task')
184
+ elif hasattr(dep, 'task'):
185
+ pred = dep.task
186
+ else:
187
+ pred = dep
188
+
189
+ if pred is self.property:
190
+ successors.append(task)
191
+ break
192
+
193
+ return successors
194
+
195
+ def _getSuccessorsWithMaxGap(self):
196
+ """
197
+ Get successors that have maxgapduration constraint on this task.
198
+
199
+ Returns list of (task, maxgapduration, gapduration) tuples.
200
+ """
201
+ result = []
202
+ for task in self.project.tasks:
203
+ if not task.leaf():
204
+ continue
205
+ deps = task.get('depends', self.scenarioIdx) or []
206
+ for dep in deps:
207
+ if isinstance(dep, dict):
208
+ pred = dep.get('task')
209
+ maxgap = dep.get('maxgapduration')
210
+ gap = dep.get('gapduration')
211
+ elif hasattr(dep, 'task'):
212
+ pred = dep.task
213
+ maxgap = getattr(dep, 'maxgapduration', None)
214
+ gap = getattr(dep, 'gapduration', None)
215
+ else:
216
+ pred = dep
217
+ maxgap = None
218
+ gap = None
219
+
220
+ if pred is self.property and maxgap:
221
+ result.append((task, maxgap, gap))
222
+ break
223
+ return result
224
+
225
+ def _getSuccessorEarliestStart(self, successor):
226
+ """
227
+ Find the earliest time a successor task can start based on its resource availability.
228
+
229
+ Returns datetime of earliest available slot.
230
+ """
231
+ from datetime import timedelta
232
+
233
+ # Get successor's allocations
234
+ allocations = successor.get('allocate', self.scenarioIdx)
235
+ if not allocations:
236
+ # No allocations - use project working time
237
+ start_idx = self.project.dateToIdx(self.project['start'])
238
+ end_idx = self.project.dateToIdx(self.project['end'])
239
+ for idx in range(start_idx, end_idx):
240
+ if self.project.isWorkingTime(idx):
241
+ return self.project.idxToDate(idx)
242
+ return self.project['end']
243
+
244
+ # Normalize allocations
245
+ alloc_data = allocations
246
+ if isinstance(allocations, list) and len(allocations) == 1 and isinstance(allocations[0], dict):
247
+ alloc_data = allocations[0]
248
+
249
+ if isinstance(alloc_data, dict):
250
+ resource_ids = alloc_data.get('resources', [])
251
+ elif isinstance(alloc_data, list):
252
+ resource_ids = alloc_data
253
+ else:
254
+ resource_ids = [alloc_data]
255
+
256
+ # Get the primary resource
257
+ resource = None
258
+ for res_id in resource_ids:
259
+ resource = self._resolve_resource(res_id)
260
+ if resource:
261
+ break
262
+
263
+ if not resource:
264
+ return self.project['start']
265
+
266
+ # Get resource's scenario data
267
+ res_scenario = resource.data[self.scenarioIdx] if resource.data else None
268
+ if res_scenario is None:
269
+ return self.project['start']
270
+
271
+ # Initialize scoreboard if needed
272
+ if res_scenario.scoreboard is None:
273
+ res_scenario.prepareScheduling()
274
+
275
+ # Find earliest slot where resource is on shift
276
+ start_idx = self.project.dateToIdx(self.project['start'])
277
+ end_idx = self.project.dateToIdx(self.project['end'])
278
+ for idx in range(start_idx, end_idx):
279
+ if res_scenario.onShift(idx):
280
+ return self.project.idxToDate(idx)
281
+
282
+ return self.project['end']
283
+
284
+ def _computeMaxGapDelayedStart(self, earliest_start, effort):
285
+ """
286
+ Compute delayed start time based on maxgapduration constraints from successors.
287
+
288
+ If any successor has maxgapduration, we need to ensure this task ends
289
+ late enough that the gap doesn't exceed maxgapduration.
290
+
291
+ Args:
292
+ earliest_start: The earliest time this task could start (from dependencies)
293
+ effort: The effort required for this task
294
+
295
+ Returns:
296
+ Delayed start time (datetime), or earliest_start if no delay needed
297
+ """
298
+ from datetime import timedelta
299
+
300
+ successors_with_maxgap = self._getSuccessorsWithMaxGap()
301
+ if not successors_with_maxgap:
302
+ return earliest_start
303
+
304
+ delayed_start = earliest_start
305
+
306
+ for successor, maxgap_str, gap_str in successors_with_maxgap:
307
+ # Find when successor can start
308
+ successor_earliest = self._getSuccessorEarliestStart(successor)
309
+
310
+ # Parse maxgapduration
311
+ maxgap_hours = self._parse_duration(maxgap_str)
312
+ gap_hours = self._parse_duration(gap_str) if gap_str else 0
313
+
314
+ # This task must end no more than maxgap_hours before successor can start
315
+ # Required end time: successor_earliest - gap_hours (to satisfy gapduration)
316
+ # But end time must be >= successor_earliest - maxgap_hours (to satisfy maxgapduration)
317
+ # So we want end time between (successor_earliest - maxgap_hours) and (successor_earliest - gap_hours)
318
+ # Ideally, end exactly at successor_earliest - gap_hours to minimize gap
319
+
320
+ desired_end = successor_earliest - timedelta(hours=gap_hours)
321
+
322
+ # Work backwards from desired_end to find required start
323
+ # For effort-based tasks, we need 'effort' hours of work before desired_end
324
+ if effort > 0:
325
+ required_start = self._computeStartFromEnd(desired_end, effort)
326
+ if required_start > delayed_start:
327
+ delayed_start = required_start
328
+
329
+ return delayed_start
330
+
331
+ def _computeStartFromEnd(self, end_time, effort):
332
+ """
333
+ Given an end time and required effort, compute when to start.
334
+
335
+ Walks backwards from end_time counting working hours until effort is met.
336
+
337
+ Args:
338
+ end_time: Desired end time (datetime)
339
+ effort: Required effort in hours
340
+
341
+ Returns:
342
+ Required start time (datetime)
343
+ """
344
+ from datetime import timedelta
345
+
346
+ # Get allocations to determine resource working hours
347
+ allocations = self.property.get('allocate', self.scenarioIdx)
348
+
349
+ # Normalize allocations
350
+ alloc_data = allocations
351
+ if allocations and isinstance(allocations, list) and len(allocations) == 1 and isinstance(allocations[0], dict):
352
+ alloc_data = allocations[0]
353
+
354
+ resource = None
355
+ if alloc_data:
356
+ if isinstance(alloc_data, dict):
357
+ resource_ids = alloc_data.get('resources', [])
358
+ elif isinstance(alloc_data, list):
359
+ resource_ids = alloc_data
360
+ else:
361
+ resource_ids = [alloc_data]
362
+
363
+ for res_id in resource_ids:
364
+ resource = self._resolve_resource(res_id)
365
+ if resource:
366
+ break
367
+
368
+ end_idx = self.project.dateToIdx(end_time)
369
+ start_idx = self.project.dateToIdx(self.project['start'])
370
+
371
+ # Count backwards from end_idx
372
+ working_slots = 0
373
+ current_idx = end_idx - 1 # Start from slot before end
374
+
375
+ while current_idx >= start_idx and working_slots < effort:
376
+ if resource:
377
+ res_scenario = resource.data[self.scenarioIdx] if resource.data else None
378
+ if res_scenario:
379
+ if res_scenario.scoreboard is None:
380
+ res_scenario.prepareScheduling()
381
+ if res_scenario.onShift(current_idx):
382
+ working_slots += 1
383
+ else:
384
+ if self.project.isWorkingTime(current_idx):
385
+ working_slots += 1
386
+ else:
387
+ if self.project.isWorkingTime(current_idx):
388
+ working_slots += 1
389
+ current_idx -= 1
390
+
391
+ return self.project.idxToDate(current_idx + 1)
392
+
393
+ def schedule(self):
394
+ if self.scheduled:
395
+ return True
396
+
397
+ # Determine start slot
398
+ forward = self.property.get('forward', self.scenarioIdx)
399
+ effort = self.property.get('effort', self.scenarioIdx) or 0
400
+ allocations = self.property.get('allocate', self.scenarioIdx)
401
+
402
+ if self.currentSlotIdx is None:
403
+ if forward:
404
+ start_date = self.property.get('start', self.scenarioIdx)
405
+ if start_date:
406
+ self.currentSlotIdx = self.project.dateToIdx(start_date)
407
+ else:
408
+ # ASAP mode, start at project start or after dependencies
409
+ # Check ALL dependencies (including inherited) to find the earliest start
410
+ earliest_start = self.project['start']
411
+ for dep in self.getAllDependencies():
412
+ # dep can be a dict with 'task' key (new format with gap),
413
+ # or a Task object directly (old format)
414
+ if isinstance(dep, dict):
415
+ t = dep.get('task')
416
+ gapduration = dep.get('gapduration')
417
+ gaplength = dep.get('gaplength')
418
+ onstart = dep.get('onstart', False)
419
+ elif hasattr(dep, 'task'):
420
+ t = dep.task
421
+ gapduration = getattr(dep, 'gapduration', None)
422
+ gaplength = getattr(dep, 'gaplength', None)
423
+ onstart = getattr(dep, 'onstart', False)
424
+ else:
425
+ t = dep
426
+ gapduration = None
427
+ gaplength = None
428
+ onstart = False
429
+
430
+ if not t:
431
+ continue
432
+
433
+ # Use start time if onstart, otherwise use end time (finish-to-start)
434
+ if onstart:
435
+ dep_time = t.get('start', self.scenarioIdx)
436
+ else:
437
+ dep_time = t.get('end', self.scenarioIdx)
438
+ if dep_time:
439
+ # Add gap if specified
440
+ if gapduration:
441
+ # gapduration is calendar time (e.g., "4h" = 4 hours)
442
+ gap_hours = self._parse_duration(gapduration)
443
+ from datetime import timedelta
444
+ dep_time = dep_time + timedelta(hours=gap_hours)
445
+ elif gaplength:
446
+ # gaplength is working time - need to find next working slot after gap
447
+ gap_hours = self._parse_duration(gaplength)
448
+ gap_slots = int(gap_hours) # Each slot is 1 hour
449
+ dep_time_idx = self.project.dateToIdx(dep_time)
450
+ # Skip gap_slots of working time
451
+ working_slots = 0
452
+ while working_slots < gap_slots:
453
+ if self.isWorkingTime(dep_time_idx):
454
+ working_slots += 1
455
+ dep_time_idx += 1
456
+ dep_time = self.project.idxToDate(dep_time_idx)
457
+ if dep_time > earliest_start:
458
+ earliest_start = dep_time
459
+
460
+ # Check for maxgapduration constraints from successors
461
+ # If a successor has maxgapduration, we may need to delay our start
462
+ # so that we end close enough for the successor to meet the constraint
463
+ if effort > 0:
464
+ delayed_start = self._computeMaxGapDelayedStart(earliest_start, effort)
465
+ if delayed_start > earliest_start:
466
+ earliest_start = delayed_start
467
+
468
+ # Convert earliest_start to slot index
469
+ # If earliest_start is mid-slot, track the offset so we don't
470
+ # book time that overlaps with the predecessor
471
+ slot_idx = self.project.dateToIdx(earliest_start)
472
+ slot_start = self.project.idxToDate(slot_idx)
473
+ if earliest_start > slot_start:
474
+ # earliest_start is mid-slot - calculate offset in seconds
475
+ offset_seconds = (earliest_start - slot_start).total_seconds()
476
+ self.slotStartOffset = offset_seconds
477
+ else:
478
+ self.slotStartOffset = 0.0
479
+ self.currentSlotIdx = slot_idx
480
+ else:
481
+ # ALAP (backward) scheduling
482
+ end_date = self.property.get('end', self.scenarioIdx)
483
+
484
+ if not end_date:
485
+ # No explicit end - derive from:
486
+ # 1. Predecessors with onstart deps (our END <= their START)
487
+ # 2. Successors (tasks depending on this - our END <= their START)
488
+ latest_end = self.project['end'] # Default to project end
489
+
490
+ # Check onstart dependencies - our END must be before predecessor's START
491
+ # with gapduration subtracted if specified
492
+ for dep in self.getAllDependencies():
493
+ if isinstance(dep, dict):
494
+ onstart = dep.get('onstart', False)
495
+ pred = dep.get('task')
496
+ gapduration = dep.get('gapduration')
497
+ elif hasattr(dep, 'task'):
498
+ onstart = getattr(dep, 'onstart', False)
499
+ pred = dep.task
500
+ gapduration = getattr(dep, 'gapduration', None)
501
+ else:
502
+ onstart = False
503
+ pred = dep
504
+ gapduration = None
505
+
506
+ if onstart and pred:
507
+ pred_start = pred.get('start', self.scenarioIdx)
508
+ if pred_start:
509
+ # Apply gapduration - A must end (gapduration) before B starts
510
+ if gapduration:
511
+ gap_hours = self._parse_duration(gapduration)
512
+ from datetime import timedelta
513
+ pred_start = pred_start - timedelta(hours=gap_hours)
514
+ if pred_start < latest_end:
515
+ latest_end = pred_start
516
+
517
+ # Also check successors (finish-to-start deps)
518
+ successors = self._getSuccessors()
519
+ for successor in successors:
520
+ succ_start = successor.get('start', self.scenarioIdx)
521
+ if succ_start and succ_start < latest_end:
522
+ latest_end = succ_start
523
+
524
+ end_date = latest_end
525
+
526
+ if end_date:
527
+ # For ALAP, start from the last working slot BEFORE the end date
528
+ self.currentSlotIdx = self.project.dateToIdx(end_date) - 1
529
+ # Find the last working slot
530
+ # For effort tasks with allocations, check resource availability
531
+ # (respects resource timezone and working hours)
532
+ lowerLimit = self.project.dateToIdx(self.project['start'])
533
+ if effort > 0 and allocations:
534
+ while self.currentSlotIdx > lowerLimit and not self._isResourceAvailable(self.currentSlotIdx):
535
+ self.currentSlotIdx -= 1
536
+ else:
537
+ while self.currentSlotIdx > lowerLimit and not self.isWorkingTime(self.currentSlotIdx):
538
+ self.currentSlotIdx -= 1
539
+ else:
540
+ # ALAP mode, end at project end
541
+ self.currentSlotIdx = self.project.dateToIdx(self.project['end']) - 1
542
+ # Find the last working slot
543
+ lowerLimit = self.project.dateToIdx(self.project['start'])
544
+ if effort > 0 and allocations:
545
+ while self.currentSlotIdx > lowerLimit and not self._isResourceAvailable(self.currentSlotIdx):
546
+ self.currentSlotIdx -= 1
547
+ else:
548
+ while self.currentSlotIdx > lowerLimit and not self.isWorkingTime(self.currentSlotIdx):
549
+ self.currentSlotIdx -= 1
550
+
551
+ # For effort tasks with allocations, don't set start yet - it will be set
552
+ # when first resource is booked. For non-effort tasks, find first working slot.
553
+ # Exception: milestones happen at the exact dependency end time (no need for working slot)
554
+ milestone = self.property.get('milestone', self.scenarioIdx)
555
+ duration = self.property.get('duration', self.scenarioIdx) or 0
556
+ length = self.property.get('length', self.scenarioIdx) or 0
557
+ is_milestone = milestone or (effort == 0 and duration == 0 and length == 0)
558
+ if forward and not self.property.get('start', self.scenarioIdx) and not is_milestone:
559
+ if effort == 0 or not allocations:
560
+ # Non-effort task: find first working slot and set start
561
+ upperLimit = self.project.dateToIdx(self.project['end'])
562
+ while self.currentSlotIdx < upperLimit and not self.isWorkingTime(self.currentSlotIdx):
563
+ self.currentSlotIdx += 1
564
+ self.property[('start', self.scenarioIdx)] = self.project.idxToDate(self.currentSlotIdx)
565
+ # For effort tasks, start will be set in bookResources() on first booking
566
+
567
+ # Record starting position for forward scheduling
568
+ start_slot_idx = self.currentSlotIdx
569
+ # For ALAP, track the first slot where we actually book (not just the constraint position)
570
+ first_booked_slot = None
571
+
572
+ delta = 1 if forward else -1
573
+ lowerLimit = self.project.dateToIdx(self.project['start'])
574
+ upperLimit = self.project.dateToIdx(self.project['end'])
575
+
576
+ previous_effort = self.doneEffort
577
+ while self.scheduleSlot():
578
+ # Track first booked slot for ALAP (when effort actually increases)
579
+ if not forward and first_booked_slot is None and self.doneEffort > previous_effort:
580
+ first_booked_slot = self.currentSlotIdx
581
+ previous_effort = self.doneEffort
582
+
583
+ self.currentSlotIdx += delta
584
+ if self.currentSlotIdx < lowerLimit or self.currentSlotIdx > upperLimit:
585
+ self.isRunAway = True
586
+ return False
587
+
588
+ # Set start/end dates based on scheduling direction
589
+ if forward:
590
+ # For forward scheduling: start is at the beginning, end is at current position
591
+ if not self.property.get('start', self.scenarioIdx):
592
+ self.property[('start', self.scenarioIdx)] = self.project.idxToDate(start_slot_idx)
593
+ else:
594
+ # For backward scheduling:
595
+ # - first_booked_slot = the actual first slot where we booked (latest, near the end)
596
+ # - currentSlotIdx = last slot scheduled (the earliest slot we booked)
597
+ # The task starts at the beginning of currentSlotIdx
598
+ # and ends after the first booked slot
599
+
600
+ # Set start time (the earliest slot we worked in)
601
+ # currentSlotIdx is the last (earliest) slot we booked
602
+ actual_start = self.project.idxToDate(self.currentSlotIdx)
603
+ if not self.property.get('start', self.scenarioIdx):
604
+ self.property[('start', self.scenarioIdx)] = actual_start
605
+
606
+ # Set end time
607
+ # For ALAP, end is based on the actual first booking, not the constraint position
608
+ # The constraint tells us when to end BY, but actual end is when work finishes
609
+ # Use first_booked_slot if we actually booked something, else fall back to start_slot_idx
610
+ end_slot = first_booked_slot if first_booked_slot is not None else start_slot_idx
611
+ actual_end = self.project.idxToDate(end_slot + 1)
612
+ # For effort-based tasks, always use the calculated end (when work actually completes)
613
+ # even if an explicit end constraint was specified (that's just the deadline, not the actual end)
614
+ effort = self.property.get('effort', self.scenarioIdx) or 0
615
+ if effort > 0 or not self.property.get('end', self.scenarioIdx):
616
+ self.property[('end', self.scenarioIdx)] = actual_end
617
+
618
+ self.scheduled = True
619
+ self.property[('scheduled', self.scenarioIdx)] = True
620
+ return True
621
+
622
+ def scheduleSlot(self):
623
+ # Determine duration type
624
+ # :effortTask, :lengthTask, :durationTask, :startEndTask, or milestone
625
+
626
+ effort = self.property.get('effort', self.scenarioIdx) or 0
627
+ length = self.property.get('length', self.scenarioIdx) or 0
628
+ duration = self.property.get('duration', self.scenarioIdx) or 0
629
+ milestone = self.property.get('milestone', self.scenarioIdx)
630
+
631
+ # We need state tracking for done effort/duration
632
+ if not hasattr(self, 'doneEffort'): self.doneEffort = 0
633
+ if not hasattr(self, 'doneDuration'): self.doneDuration = 0
634
+ if not hasattr(self, 'doneLength'): self.doneLength = 0
635
+
636
+ forward = self.property.get('forward', self.scenarioIdx)
637
+
638
+ # A task with no effort/duration/length is a milestone (zero duration task)
639
+ # This includes tasks that only have dependencies but no work
640
+ start_date = self.property.get('start', self.scenarioIdx)
641
+ end_date = self.property.get('end', self.scenarioIdx)
642
+ is_milestone = milestone or (effort == 0 and duration == 0 and length == 0)
643
+
644
+ if is_milestone:
645
+ # Milestone: set end = start (zero duration)
646
+ if forward:
647
+ if start_date:
648
+ self.property[('end', self.scenarioIdx)] = start_date
649
+ else:
650
+ # No start date - use current slot (set by dependency calculation)
651
+ date = self.project.idxToDate(self.currentSlotIdx)
652
+ self.property[('start', self.scenarioIdx)] = date
653
+ self.property[('end', self.scenarioIdx)] = date
654
+ else:
655
+ if end_date:
656
+ self.property[('start', self.scenarioIdx)] = end_date
657
+ else:
658
+ date = self.project.idxToDate(self.currentSlotIdx)
659
+ self.property[('start', self.scenarioIdx)] = date
660
+ self.property[('end', self.scenarioIdx)] = date
661
+ return False
662
+
663
+ if effort > 0:
664
+ # Check for contiguous flag - task cannot be split across breaks
665
+ flags = self.property.get('flags', self.scenarioIdx) or []
666
+ if 'contiguous' in flags and self.doneEffort == 0:
667
+ # Before starting, verify we have a contiguous block large enough
668
+ if not self._hasContiguousBlock(effort):
669
+ # Skip this slot - no contiguous block starts here
670
+ return True # Continue to next slot
671
+
672
+ # Store effort before booking to calculate fraction used in final slot
673
+ effort_before = self.doneEffort
674
+ self.bookResources()
675
+
676
+ if self.doneEffort >= effort:
677
+ # Finished - calculate precise end time within the final slot
678
+ # and release unused time for other tasks
679
+ end_date, seconds_used = self._calculatePreciseEndTimeAndRelease(
680
+ effort, effort_before, forward
681
+ )
682
+ self.propagateDate(end_date, forward)
683
+ return False
684
+ elif duration > 0:
685
+ self.bookResources() # Even if just duration, might use resources?
686
+ self.doneDuration += 1
687
+ if self.doneDuration >= duration:
688
+ date = self.project.idxToDate(self.currentSlotIdx + (1 if forward else 0))
689
+ self.propagateDate(date, forward)
690
+ return False
691
+ else:
692
+ # startEndTask - has both start and end dates explicitly set
693
+ self.bookResources()
694
+ # Check if reached end/start
695
+ target_date = end_date if forward else start_date
696
+ if target_date:
697
+ target_idx = self.project.dateToIdx(target_date)
698
+ if (forward and self.currentSlotIdx >= target_idx) or (not forward and self.currentSlotIdx <= target_idx):
699
+ return False
700
+
701
+ return True
702
+
703
+ def _calculatePreciseEndTimeAndRelease(self, required_effort, effort_before_slot, forward):
704
+ """
705
+ Calculate the precise end time within the final slot and release unused time.
706
+
707
+ When a task completes within a slot, we need to determine exactly when
708
+ within that slot the required effort was reached, rather than rounding
709
+ to slot boundaries. The unused portion of the slot is released back to
710
+ the resource for other tasks to use.
711
+
712
+ Args:
713
+ required_effort: Total effort required for the task (hours)
714
+ effort_before_slot: Effort accumulated before the current slot (hours)
715
+ forward: True for forward scheduling, False for backward
716
+
717
+ Returns:
718
+ tuple: (precise_end_datetime, seconds_used_in_slot)
719
+ """
720
+ from datetime import timedelta
721
+
722
+ # Get slot parameters
723
+ slot_duration_seconds = self.project.attributes.get('scheduleGranularity', 3600)
724
+ slot_start = self.project.idxToDate(self.currentSlotIdx)
725
+
726
+ # Get the resource and its efficiency for this slot
727
+ resource = getattr(self, '_lastBookedResource', None)
728
+ efficiency = 1.0
729
+ if resource:
730
+ eff = resource.get('efficiency', self.scenarioIdx)
731
+ if eff is not None:
732
+ efficiency = eff
733
+ else:
734
+ # Fallback to allocations
735
+ allocations = self.property.get('allocate', self.scenarioIdx) or []
736
+ for alloc in allocations:
737
+ if isinstance(alloc, str):
738
+ for res in self.project.resources:
739
+ if res.id == alloc:
740
+ resource = res
741
+ break
742
+ else:
743
+ resource = alloc
744
+ if resource:
745
+ eff = resource.get('efficiency', self.scenarioIdx)
746
+ if eff is not None:
747
+ efficiency = eff
748
+ break
749
+
750
+ # Calculate effort gained per second in this slot
751
+ slot_duration_hours = slot_duration_seconds / 3600.0
752
+ effort_per_slot = slot_duration_hours * efficiency
753
+ effort_per_second = effort_per_slot / slot_duration_seconds
754
+
755
+ # How much effort was needed in this final slot?
756
+ effort_needed_in_slot = required_effort - effort_before_slot
757
+
758
+ # How many seconds into the slot does that take?
759
+ if effort_per_second > 0:
760
+ seconds_into_slot = effort_needed_in_slot / effort_per_second
761
+ else:
762
+ seconds_into_slot = slot_duration_seconds
763
+
764
+ # Clamp to slot duration (shouldn't exceed, but safety check)
765
+ seconds_into_slot = min(seconds_into_slot, slot_duration_seconds)
766
+
767
+ # Calculate the precise end time, rounded to nearest second
768
+ # (Gold standard uses second-level precision)
769
+ seconds_rounded = round(seconds_into_slot)
770
+
771
+ if forward:
772
+ # For forward scheduling, end time is offset from slot start
773
+ precise_end = slot_start + timedelta(seconds=seconds_rounded)
774
+ else:
775
+ # For backward scheduling, we're calculating the START time
776
+ # The start is at the END of the slot minus unused time
777
+ # If we used the whole slot, start is at slot_start
778
+ # If we used part of it, start is later in the slot
779
+ slot_end = slot_start + timedelta(seconds=slot_duration_seconds)
780
+ precise_end = slot_end - timedelta(seconds=seconds_rounded)
781
+
782
+ # Release unused portion of the slot back to the resource
783
+ seconds_unused = slot_duration_seconds - seconds_into_slot
784
+ if seconds_unused > 0 and resource:
785
+ res_scenario = resource.data[self.scenarioIdx] if resource.data else None
786
+ if res_scenario:
787
+ # Update the per-task usage record to reflect actual usage
788
+ if self.currentSlotIdx in res_scenario.slotTaskUsage:
789
+ # Find and update this task's entry
790
+ for i, (task, secs) in enumerate(res_scenario.slotTaskUsage[self.currentSlotIdx]):
791
+ if task == self.property:
792
+ res_scenario.slotTaskUsage[self.currentSlotIdx][i] = (task, seconds_into_slot)
793
+ break
794
+
795
+ # Update total slotSecondsUsed to release unused time
796
+ # Old value was full slot duration, new value is actual usage
797
+ old_total = res_scenario.slotSecondsUsed.get(self.currentSlotIdx, slot_duration_seconds)
798
+ # Subtract what was previously booked (full slot) and add actual usage
799
+ res_scenario.slotSecondsUsed[self.currentSlotIdx] = old_total - slot_duration_seconds + seconds_into_slot
800
+
801
+ return precise_end, seconds_into_slot
802
+
803
+ def _calculatePreciseEndTime(self, required_effort, effort_before_slot, forward):
804
+ """
805
+ Calculate the precise end time within the final slot based on fractional effort.
806
+ (Legacy method - calls the new implementation)
807
+ """
808
+ end_time, _ = self._calculatePreciseEndTimeAndRelease(required_effort, effort_before_slot, forward)
809
+ return end_time
810
+
811
+ def _parse_duration(self, duration_str):
812
+ """
813
+ Parse a duration string like '4h', '2d', '1w', '30min' into hours.
814
+ """
815
+ if not duration_str:
816
+ return 0
817
+ import re
818
+ # Match formats: 29min, 4h, 2d, 1w, 3m (months), 1y
819
+ match = re.match(r'(\d+(?:\.\d+)?)\s*(min|h|d|w|m|y)?', str(duration_str).lower())
820
+ if not match:
821
+ return 0
822
+ num = float(match.group(1))
823
+ unit = match.group(2) or 'h'
824
+ multipliers = {'min': 1/60, 'h': 1, 'd': 8, 'w': 40, 'm': 160, 'y': 1920}
825
+ return num * multipliers.get(unit, 1)
826
+
827
+ def isWorkingTime(self, slotIdx):
828
+ """
829
+ Check if a slot index falls within working hours.
830
+
831
+ Delegates to project.isWorkingTime which checks:
832
+ - Weekday (Mon-Fri)
833
+ - Working hours (9am-5pm default)
834
+ - Global vacations
835
+ - Shift-specific schedules
836
+
837
+ Returns True if the slot is during working time.
838
+ """
839
+ return self.project.isWorkingTime(slotIdx)
840
+
841
+ def _isResourceAvailable(self, slotIdx):
842
+ """
843
+ Check if any allocated resource is available at the given slot.
844
+
845
+ For effort-based tasks with allocations, this checks the actual resource
846
+ availability (considering their timezone and working hours) rather than
847
+ the project's default working hours.
848
+
849
+ Args:
850
+ slotIdx: Scoreboard index to check
851
+
852
+ Returns:
853
+ True if at least one allocated resource is available
854
+ """
855
+ allocations = self.property.get('allocate', self.scenarioIdx)
856
+ if not allocations:
857
+ # No allocations - fall back to project working time
858
+ return self.project.isWorkingTime(slotIdx)
859
+
860
+ # Parse allocations - handle both simple list and dict with alternatives
861
+ resource_ids = []
862
+
863
+ # Normalize allocations
864
+ alloc_data = allocations
865
+ if isinstance(allocations, list) and len(allocations) == 1 and isinstance(allocations[0], dict):
866
+ alloc_data = allocations[0]
867
+
868
+ if isinstance(alloc_data, dict):
869
+ resource_ids = alloc_data.get('resources', [])
870
+ # Also include alternatives
871
+ alternatives = alloc_data.get('options', {}).get('alternative', [])
872
+ resource_ids = resource_ids + alternatives
873
+ elif isinstance(alloc_data, list):
874
+ resource_ids = alloc_data
875
+ else:
876
+ resource_ids = [alloc_data]
877
+
878
+ # Check each allocated resource
879
+ for alloc in resource_ids:
880
+ resource = self._resolve_resource(alloc)
881
+ if resource is None:
882
+ continue
883
+
884
+ # Get resource's scenario data
885
+ res_scenario = resource.data[self.scenarioIdx] if resource.data else None
886
+ if res_scenario is None:
887
+ continue
888
+
889
+ # Initialize scoreboard if needed
890
+ if res_scenario.scoreboard is None:
891
+ res_scenario.prepareScheduling()
892
+
893
+ # Check if resource is on shift at this slot
894
+ if res_scenario.onShift(slotIdx):
895
+ return True
896
+
897
+ return False
898
+
899
+ def _hasContiguousBlock(self, effort):
900
+ """
901
+ Check if there's a contiguous block of working time starting from current slot
902
+ that can fit the required effort.
903
+
904
+ For contiguous (atomic) tasks, we need to ensure the task won't be split
905
+ across breaks (like lunch breaks). The entire effort must fit in one
906
+ continuous working period.
907
+
908
+ Args:
909
+ effort: Required effort in hours
910
+
911
+ Returns:
912
+ True if a contiguous block large enough exists starting at current slot
913
+ """
914
+ from datetime import timedelta
915
+
916
+ # Get allocations to check resource availability
917
+ allocations = self.property.get('allocate', self.scenarioIdx)
918
+ if not allocations:
919
+ # No allocations - check project working time
920
+ return self._checkProjectContiguousBlock(effort)
921
+
922
+ # Normalize allocations
923
+ alloc_data = allocations
924
+ if isinstance(allocations, list) and len(allocations) == 1 and isinstance(allocations[0], dict):
925
+ alloc_data = allocations[0]
926
+
927
+ if isinstance(alloc_data, dict):
928
+ resource_ids = alloc_data.get('resources', [])
929
+ elif isinstance(alloc_data, list):
930
+ resource_ids = alloc_data
931
+ else:
932
+ resource_ids = [alloc_data]
933
+
934
+ # Get the first resource (for contiguous check, we use primary resource's availability)
935
+ resource = None
936
+ for res_id in resource_ids:
937
+ resource = self._resolve_resource(res_id)
938
+ if resource:
939
+ break
940
+
941
+ if not resource:
942
+ return self._checkProjectContiguousBlock(effort)
943
+
944
+ # Get resource's scenario data
945
+ res_scenario = resource.data[self.scenarioIdx] if resource.data else None
946
+ if res_scenario is None:
947
+ return self._checkProjectContiguousBlock(effort)
948
+
949
+ # Initialize scoreboard if needed
950
+ if res_scenario.scoreboard is None:
951
+ res_scenario.prepareScheduling()
952
+
953
+ # Get efficiency
954
+ efficiency = resource.get('efficiency', self.scenarioIdx) or 1.0
955
+
956
+ # Calculate required duration (hours of actual clock time)
957
+ required_duration = effort / efficiency
958
+
959
+ # Get slot duration in hours
960
+ slot_duration_sec = self.project.attributes.get('scheduleGranularity', 3600)
961
+ slot_duration_hours = slot_duration_sec / 3600.0
962
+
963
+ # Calculate how many consecutive slots we need
964
+ slots_needed = required_duration / slot_duration_hours
965
+
966
+ # Check if we have that many consecutive working slots starting from current
967
+ consecutive_count = 0
968
+ current_slot = self.currentSlotIdx
969
+ max_slots = len(res_scenario.scoreboard) if res_scenario.scoreboard else 1000
970
+
971
+ while current_slot < max_slots and consecutive_count < slots_needed:
972
+ if res_scenario.available(current_slot):
973
+ if consecutive_count == 0:
974
+ # First available slot - check if it's the current slot
975
+ if current_slot != self.currentSlotIdx:
976
+ # Gap before first available - not contiguous from current
977
+ return False
978
+ consecutive_count += 1
979
+ current_slot += 1
980
+ else:
981
+ # Hit a break/unavailable slot
982
+ if consecutive_count > 0:
983
+ # Already started counting but hit a break - not enough contiguous
984
+ return False
985
+ else:
986
+ # Haven't found starting slot yet - not available at current
987
+ return False
988
+
989
+ return consecutive_count >= slots_needed
990
+
991
+ def _checkProjectContiguousBlock(self, effort):
992
+ """
993
+ Fallback check for contiguous block using project working time.
994
+ """
995
+ slot_duration_sec = self.project.attributes.get('scheduleGranularity', 3600)
996
+ slot_duration_hours = slot_duration_sec / 3600.0
997
+ slots_needed = effort / slot_duration_hours
998
+
999
+ consecutive_count = 0
1000
+ current_slot = self.currentSlotIdx
1001
+ max_slots = 1000
1002
+
1003
+ while current_slot < max_slots and consecutive_count < slots_needed:
1004
+ if self.project.isWorkingTime(current_slot):
1005
+ if consecutive_count == 0 and current_slot != self.currentSlotIdx:
1006
+ return False
1007
+ consecutive_count += 1
1008
+ current_slot += 1
1009
+ else:
1010
+ if consecutive_count > 0:
1011
+ return False
1012
+ else:
1013
+ return False
1014
+
1015
+ return consecutive_count >= slots_needed
1016
+
1017
+ def _resolve_resource(self, alloc):
1018
+ """
1019
+ Resolve a resource allocation to an actual Resource object.
1020
+
1021
+ Args:
1022
+ alloc: Either a resource ID string or a Resource object
1023
+
1024
+ Returns:
1025
+ The Resource object or None if not found
1026
+ """
1027
+ if isinstance(alloc, str):
1028
+ # Try indexed lookup first
1029
+ resource = self.project.resources.get(alloc) if hasattr(self.project.resources, 'get') else None
1030
+ if resource is None:
1031
+ # Fall back to iteration
1032
+ for res in self.project.resources:
1033
+ if res.id == alloc:
1034
+ return res
1035
+ return resource
1036
+ return alloc
1037
+
1038
+ def _selectBestResources(self, primary_resources, alternative_resources, effort):
1039
+ """
1040
+ Select the best resources for this task using smart routing.
1041
+
1042
+ For tasks with alternatives, this compares completion times:
1043
+ - Path A: Wait for primary resource to become available
1044
+ - Path B: Start now with alternative resource
1045
+
1046
+ The path that finishes earlier wins.
1047
+
1048
+ Args:
1049
+ primary_resources: List of primary (preferred) resources
1050
+ alternative_resources: List of alternative (fallback) resources
1051
+ effort: Required effort in hours
1052
+
1053
+ Returns:
1054
+ List of resources to book
1055
+ """
1056
+ if not primary_resources and not alternative_resources:
1057
+ return []
1058
+
1059
+ # If no alternatives, use primary resources
1060
+ if not alternative_resources:
1061
+ return primary_resources
1062
+
1063
+ # If no primaries, use alternatives
1064
+ if not primary_resources:
1065
+ return alternative_resources
1066
+
1067
+ # Smart routing: compare completion times
1068
+ # Calculate when each path would complete the task
1069
+
1070
+ primary_end = self._estimateCompletionTime(primary_resources, effort)
1071
+ alternative_end = self._estimateCompletionTime(alternative_resources, effort)
1072
+
1073
+ # Choose the path that finishes earlier
1074
+ if alternative_end is not None and (primary_end is None or alternative_end < primary_end):
1075
+ # Store which resource was selected for reporting
1076
+ if not hasattr(self, '_selectedAlternative'):
1077
+ self._selectedAlternative = True
1078
+ return alternative_resources
1079
+ else:
1080
+ if not hasattr(self, '_selectedAlternative'):
1081
+ self._selectedAlternative = False
1082
+ return primary_resources
1083
+
1084
+ def _estimateCompletionTime(self, resources, effort):
1085
+ """
1086
+ Estimate when a task would complete using the given resources.
1087
+
1088
+ Args:
1089
+ resources: List of resources to use
1090
+ effort: Required effort in hours
1091
+
1092
+ Returns:
1093
+ Estimated completion datetime or None if cannot complete
1094
+ """
1095
+ if not resources or effort <= 0:
1096
+ return None
1097
+
1098
+ from datetime import timedelta
1099
+
1100
+ # Get efficiency (use first resource's efficiency)
1101
+ resource = resources[0]
1102
+ efficiency = resource.get('efficiency', self.scenarioIdx) or 1.0
1103
+
1104
+ # Duration = effort / efficiency
1105
+ duration_hours = effort / efficiency
1106
+
1107
+ # Find the first available slot for this resource
1108
+ res_scenario = resource.data[self.scenarioIdx] if resource.data else None
1109
+ if res_scenario is None:
1110
+ return None
1111
+
1112
+ if res_scenario.scoreboard is None:
1113
+ res_scenario.prepareScheduling()
1114
+
1115
+ # Simulate scheduling to find completion time
1116
+ slot_duration = self.project.attributes.get('scheduleGranularity', 3600)
1117
+ effort_per_slot = (slot_duration / 3600.0) * efficiency
1118
+
1119
+ remaining_effort = effort
1120
+ current_slot = self.currentSlotIdx
1121
+
1122
+ # Safety limit to prevent infinite loops
1123
+ max_slots = len(res_scenario.scoreboard) if res_scenario.scoreboard else 1000
1124
+
1125
+ while remaining_effort > 0 and current_slot < max_slots:
1126
+ if res_scenario.available(current_slot):
1127
+ remaining_effort -= effort_per_slot
1128
+ current_slot += 1
1129
+
1130
+ if remaining_effort > 0:
1131
+ return None # Cannot complete within project timeframe
1132
+
1133
+ # Calculate the end time
1134
+ # current_slot is now one past the last booked slot
1135
+ end_slot = current_slot - 1
1136
+ end_time = self.project.idxToDate(end_slot) + timedelta(seconds=slot_duration)
1137
+
1138
+ return end_time
1139
+
1140
+ def bookResources(self):
1141
+ """
1142
+ Book resources for the current slot and accumulate effort.
1143
+
1144
+ This method attempts to book allocated resources for the current time slot.
1145
+ For effort-based tasks with multiple allocations, ALL resources must be
1146
+ available before any are booked (they work together as a team).
1147
+
1148
+ For continuous time scheduling, effort is tracked based on actual available
1149
+ time in slots (accounting for partial slot usage by other tasks).
1150
+
1151
+ Supports alternative resources: if primary is unavailable, tries alternatives.
1152
+ Smart routing picks the resource that finishes the task earliest.
1153
+ """
1154
+ # Get allocations
1155
+ allocations = self.property.get('allocate', self.scenarioIdx)
1156
+ if not allocations:
1157
+ return
1158
+
1159
+ effort = self.property.get('effort', self.scenarioIdx) or 0
1160
+
1161
+ # Parse allocations - handle both simple list and dict with alternatives
1162
+ primary_resources = []
1163
+ alternative_resources = []
1164
+
1165
+ # Normalize allocations - can be list of strings, list containing dict, or dict
1166
+ alloc_data = allocations
1167
+ if isinstance(allocations, list) and len(allocations) == 1 and isinstance(allocations[0], dict):
1168
+ # List containing a single dict with options
1169
+ alloc_data = allocations[0]
1170
+
1171
+ if isinstance(alloc_data, dict):
1172
+ # New format with options: {'resources': [...], 'options': {...}}
1173
+ resource_ids = alloc_data.get('resources', [])
1174
+ options = alloc_data.get('options', {})
1175
+ alternative_ids = options.get('alternative', [])
1176
+
1177
+ for res_id in resource_ids:
1178
+ resource = self._resolve_resource(res_id)
1179
+ if resource:
1180
+ primary_resources.append(resource)
1181
+
1182
+ for res_id in alternative_ids:
1183
+ resource = self._resolve_resource(res_id)
1184
+ if resource:
1185
+ alternative_resources.append(resource)
1186
+ elif isinstance(alloc_data, list):
1187
+ # Simple list format
1188
+ for alloc in alloc_data:
1189
+ resource = self._resolve_resource(alloc)
1190
+ if resource:
1191
+ primary_resources.append(resource)
1192
+ else:
1193
+ # Single resource
1194
+ resource = self._resolve_resource(alloc_data)
1195
+ if resource:
1196
+ primary_resources.append(resource)
1197
+
1198
+ # Determine which resources to try booking
1199
+ # Smart routing: pick the resource that can complete the task earliest
1200
+ # Only select once at the beginning of scheduling (when no effort done yet)
1201
+ if not hasattr(self, '_selectedResources') or self._selectedResources is None:
1202
+ self._selectedResources = self._selectBestResources(
1203
+ primary_resources, alternative_resources, effort
1204
+ )
1205
+ resources_to_book = self._selectedResources
1206
+
1207
+ if not resources_to_book:
1208
+ return
1209
+
1210
+ # For effort-based tasks with multiple resources, ALL must be available
1211
+ # (they work together as a team - can't progress if any member is unavailable)
1212
+ if effort > 0 and len(resources_to_book) > 1:
1213
+ all_available = True
1214
+ for resource in resources_to_book:
1215
+ res_scenario = resource.data[self.scenarioIdx] if resource.data else None
1216
+ if res_scenario is None:
1217
+ all_available = False
1218
+ break
1219
+ if res_scenario.scoreboard is None:
1220
+ res_scenario.prepareScheduling()
1221
+ if not res_scenario.available(self.currentSlotIdx):
1222
+ all_available = False
1223
+ break
1224
+ # Also check task limits
1225
+ if not self.limitsOk(self.currentSlotIdx, resource):
1226
+ all_available = False
1227
+ break
1228
+
1229
+ if not all_available:
1230
+ # Can't book - one or more resources unavailable
1231
+ return
1232
+
1233
+ # Now book all resources (or single resource for non-team tasks)
1234
+ booked_any = False
1235
+ total_effort_this_slot = 0.0
1236
+ for resource in resources_to_book:
1237
+ effort_gained = self.bookResource(resource)
1238
+ if effort_gained > 0:
1239
+ booked_any = True
1240
+ # Track maximum effort from any single resource (not sum)
1241
+ # For multi-resource effort tasks, we count clock time not person-hours
1242
+ total_effort_this_slot = max(total_effort_this_slot, effort_gained)
1243
+
1244
+ # Store the resource and slot for potential partial release later
1245
+ if not hasattr(self, '_lastBookedResource'):
1246
+ self._lastBookedResource = None
1247
+ self._lastBookedSlot = None
1248
+ self._lastBookedResource = resource
1249
+ self._lastBookedSlot = self.currentSlotIdx
1250
+
1251
+ if booked_any:
1252
+ # For effort-based tasks, set start date on first booking
1253
+ if effort > 0 and self.doneEffort == 0:
1254
+ forward = self.property.get('forward', self.scenarioIdx)
1255
+ if forward:
1256
+ # Use exact start time (including mid-slot offset from dependency)
1257
+ from datetime import timedelta
1258
+ start_date = self.project.idxToDate(self.currentSlotIdx)
1259
+ if hasattr(self, 'slotStartOffset') and self.slotStartOffset > 0:
1260
+ start_date = start_date + timedelta(seconds=self.slotStartOffset)
1261
+ self.property[('start', self.scenarioIdx)] = start_date
1262
+
1263
+ # Accumulate effort (counted once per slot, not per resource)
1264
+ self.doneEffort += total_effort_this_slot
1265
+
1266
+ def getAllLimits(self):
1267
+ """
1268
+ Collect limits from this task and all parent tasks.
1269
+ Returns a list of Limits objects.
1270
+ """
1271
+ all_limits = []
1272
+ task = self.property
1273
+ while task is not None:
1274
+ limits = task.get('limits', self.scenarioIdx)
1275
+ if limits:
1276
+ all_limits.append(limits)
1277
+ task = task.parent
1278
+ return all_limits
1279
+
1280
+ def limitsOk(self, sbIdx, resource=None):
1281
+ """
1282
+ Check if all task limits (including parent limits) are satisfied.
1283
+
1284
+ Args:
1285
+ sbIdx: Scoreboard index to check
1286
+ resource: Resource to check (for resource-specific limits)
1287
+
1288
+ Returns:
1289
+ True if all limits are satisfied
1290
+ """
1291
+ for limits in self.getAllLimits():
1292
+ if not limits.ok(sbIdx, upper=True, resource=resource.id if resource else None):
1293
+ return False
1294
+ return True
1295
+
1296
+ def incLimits(self, sbIdx, resource=None):
1297
+ """
1298
+ Increment all task limit counters (including parent limits).
1299
+
1300
+ Args:
1301
+ sbIdx: Scoreboard index
1302
+ resource: Resource being booked (for resource-specific limits)
1303
+ """
1304
+ for limits in self.getAllLimits():
1305
+ limits.inc(sbIdx, resource=resource.id if resource else None)
1306
+
1307
+ def bookResource(self, resource):
1308
+ """
1309
+ Try to book a single resource for the current slot.
1310
+
1311
+ Args:
1312
+ resource: The resource to book
1313
+
1314
+ Returns:
1315
+ Effort gained from this booking (hours), or 0 if booking failed.
1316
+ This accounts for partial slot availability.
1317
+ """
1318
+ # Get the resource's scenario data
1319
+ res_scenario = resource.data[self.scenarioIdx] if resource.data else None
1320
+ if res_scenario is None:
1321
+ return 0.0
1322
+
1323
+ # Initialize resource scoreboard if needed
1324
+ if res_scenario.scoreboard is None:
1325
+ res_scenario.prepareScheduling()
1326
+
1327
+ # For the FIRST slot of this task, apply start offset from dependency
1328
+ # This marks the portion already used by predecessor as unavailable
1329
+ if hasattr(self, 'slotStartOffset') and self.slotStartOffset > 0 and self.doneEffort == 0:
1330
+ # Mark the offset portion as used (by predecessor task)
1331
+ current_used = res_scenario.slotSecondsUsed.get(self.currentSlotIdx, 0.0)
1332
+ if current_used < self.slotStartOffset:
1333
+ res_scenario.slotSecondsUsed[self.currentSlotIdx] = self.slotStartOffset
1334
+
1335
+ # Check if resource is available
1336
+ if not res_scenario.available(self.currentSlotIdx):
1337
+ return 0.0
1338
+
1339
+ # Check task limits for this resource (including parent limits)
1340
+ if not self.limitsOk(self.currentSlotIdx, resource):
1341
+ return 0.0
1342
+
1343
+ # Book the resource - returns effort gained (accounts for partial slots)
1344
+ return res_scenario.book(self.currentSlotIdx, self.property)
1345
+
1346
+ def propagateDate(self, date, atEnd):
1347
+ attr = 'end' if atEnd else 'start'
1348
+ self.property[(attr, self.scenarioIdx)] = date
1349
+ # Propagate to dependencies?
1350
+
1351
+ def finishScheduling(self):
1352
+ """
1353
+ Finish scheduling for this task.
1354
+ For container tasks, compute start/end from children.
1355
+ """
1356
+ # Recursively process children first
1357
+ for child in self.property.children:
1358
+ child_scenario = child.data[self.scenarioIdx] if child.data else None
1359
+ if child_scenario and hasattr(child_scenario, 'finishScheduling'):
1360
+ child_scenario.finishScheduling()
1361
+
1362
+ # For container tasks, set dates from children
1363
+ if not self.property.leaf():
1364
+ self.scheduleContainer()
1365
+
1366
+ def scheduleContainer(self):
1367
+ """
1368
+ Compute and set start/end dates for a container task based on its children.
1369
+ """
1370
+ if self.scheduled or self.property.leaf():
1371
+ return
1372
+
1373
+ n_start = None
1374
+ n_end = None
1375
+
1376
+ for child in self.property.children:
1377
+ child_scenario = child.data[self.scenarioIdx] if child.data else None
1378
+ if not child_scenario:
1379
+ continue
1380
+
1381
+ # Abort if a child has not been scheduled
1382
+ if not child.get('scheduled', self.scenarioIdx):
1383
+ return
1384
+
1385
+ child_start = child.get('start', self.scenarioIdx)
1386
+ child_end = child.get('end', self.scenarioIdx)
1387
+
1388
+ if child_start is None or child_end is None:
1389
+ return
1390
+
1391
+ if n_start is None or child_start < n_start:
1392
+ n_start = child_start
1393
+ if n_end is None or child_end > n_end:
1394
+ n_end = child_end
1395
+
1396
+ # Set the container dates
1397
+ current_start = self.property.get('start', self.scenarioIdx)
1398
+ current_end = self.property.get('end', self.scenarioIdx)
1399
+
1400
+ if n_start and (current_start is None or current_start > n_start):
1401
+ self.property[('start', self.scenarioIdx)] = n_start
1402
+
1403
+ if n_end and (current_end is None or current_end < n_end):
1404
+ self.property[('end', self.scenarioIdx)] = n_end
1405
+
1406
+ if n_start and n_end:
1407
+ self.scheduled = True
1408
+ self.property[('scheduled', self.scenarioIdx)] = True
1409
+
1410
+ def _getResourcesForTask(self):
1411
+ """
1412
+ Get the actual Resource objects for this task.
1413
+
1414
+ Looks up resources from either 'assignedresources' (if populated)
1415
+ or 'allocate' (resource IDs), resolving them to Resource objects.
1416
+
1417
+ Returns:
1418
+ List of Resource objects
1419
+ """
1420
+ resources = []
1421
+
1422
+ # Try assignedresources first
1423
+ assigned = self.property.get('assignedresources', self.scenarioIdx) or []
1424
+ if assigned:
1425
+ return assigned
1426
+
1427
+ # Fall back to allocate (which may contain IDs or resource objects)
1428
+ allocate = self.property.get('allocate', self.scenarioIdx) or []
1429
+ for res in allocate:
1430
+ if isinstance(res, str):
1431
+ # Look up resource by ID
1432
+ for resource in self.project.resources:
1433
+ if resource.id == res:
1434
+ resources.append(resource)
1435
+ break
1436
+ else:
1437
+ # Already a resource object
1438
+ resources.append(res)
1439
+
1440
+ return resources
1441
+
1442
+ def getCost(self):
1443
+ """
1444
+ Calculate the cost for this task based on allocated time and resource rates.
1445
+
1446
+ Cost is calculated as: allocated_time × resource_rate
1447
+ where allocated_time is the actual duration (not effort).
1448
+
1449
+ For efficiency > 1.0, allocated_time < effort
1450
+ For efficiency < 1.0, allocated_time > effort
1451
+
1452
+ Returns:
1453
+ The total cost for this task
1454
+ """
1455
+ total_cost = 0.0
1456
+
1457
+ # Get resources for this task
1458
+ resources = self._getResourcesForTask()
1459
+
1460
+ for resource in resources:
1461
+ # Get the resource's scenario data
1462
+ res_scenario = resource.data[self.scenarioIdx] if resource.data else None
1463
+ if res_scenario is None:
1464
+ continue
1465
+
1466
+ # Get resource rate
1467
+ rate = resource.get('rate', self.scenarioIdx) or 0.0
1468
+ if rate == 0.0:
1469
+ continue
1470
+
1471
+ # Use slotTaskUsage to get exact time used by this task
1472
+ allocated_seconds = 0.0
1473
+ for slot_idx, task_list in res_scenario.slotTaskUsage.items():
1474
+ for task, seconds in task_list:
1475
+ if task == self.property:
1476
+ allocated_seconds += seconds
1477
+
1478
+ allocated_hours = allocated_seconds / 3600.0
1479
+ total_cost += allocated_hours * rate
1480
+
1481
+ return total_cost
1482
+
1483
+ def getAllocatedTime(self):
1484
+ """
1485
+ Calculate the total allocated time (duration) for this task.
1486
+
1487
+ This is the actual calendar time spent on the task, which differs
1488
+ from effort when resource efficiency != 1.0.
1489
+
1490
+ Returns:
1491
+ The allocated time in hours
1492
+ """
1493
+ total_allocated = 0.0
1494
+
1495
+ # Get resources for this task
1496
+ resources = self._getResourcesForTask()
1497
+
1498
+ for resource in resources:
1499
+ # Get the resource's scenario data
1500
+ res_scenario = resource.data[self.scenarioIdx] if resource.data else None
1501
+ if res_scenario is None or res_scenario.scoreboard is None:
1502
+ continue
1503
+
1504
+ # Count slots booked for this task by this resource
1505
+ booked_slots = 0
1506
+ for i in range(len(res_scenario.scoreboard)):
1507
+ if res_scenario.scoreboard[i] == self.property:
1508
+ booked_slots += 1
1509
+
1510
+ # Calculate allocated time in hours
1511
+ granularity = self.project.attributes.get('scheduleGranularity', 3600)
1512
+ allocated_hours = booked_slots * granularity / 3600.0
1513
+ total_allocated += allocated_hours
1514
+
1515
+ return total_allocated