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,711 @@
1
+ """
2
+ ResourceScenario - Scenario-specific data for resources.
3
+
4
+ This module implements the ResourceScenario class which holds all
5
+ scenario-specific data for a Resource.
6
+ """
7
+
8
+ from typing import TYPE_CHECKING, Optional, List, Any, Dict, Callable
9
+
10
+ from scriptplan.core.scenario_data import ScenarioData
11
+ from scriptplan.scheduler.scoreboard import Scoreboard
12
+ from scriptplan.utils.data_cache import DataCache
13
+ from scriptplan.core.leave import Leave
14
+ from scriptplan.core.booking import Booking
15
+ from scriptplan.utils.time import TimeInterval
16
+
17
+ if TYPE_CHECKING:
18
+ from scriptplan.core.resource import Resource
19
+ from scriptplan.core.task import Task
20
+
21
+
22
+ class ResourceScenario(ScenarioData):
23
+ """
24
+ Scenario-specific data for a Resource.
25
+
26
+ This class holds all scenario-specific attributes and methods for resources,
27
+ including scoreboard management, booking, and effort tracking.
28
+ """
29
+
30
+ def __init__(self, resource: 'Resource', scenario_idx: int, attributes: Any):
31
+ """
32
+ Initialize ResourceScenario.
33
+
34
+ Args:
35
+ resource: The parent Resource
36
+ scenario_idx: The scenario index
37
+ attributes: Attribute definitions
38
+ """
39
+ super().__init__(resource, scenario_idx, attributes)
40
+
41
+ # Scoreboard may be nil, a Task, or a bit vector encoded as an Integer
42
+ # nil: Value has not been determined yet.
43
+ # Task: A reference to a Task object
44
+ # Bit 0: Reserved
45
+ # Bit 1: 0: Work time (as defined by working hours)
46
+ # 1: No work time (as defined by working hours)
47
+ # Bit 2 - 5: See Leave class for actual values.
48
+ # Bit 6 - 7: Reserved
49
+ # Bit 8: 0: No global override
50
+ # 1: Override global setting
51
+ self.scoreboard: Optional[Scoreboard] = None
52
+
53
+ # The index of the earliest booked time slot
54
+ self.firstBookedSlot: Optional[int] = None
55
+ # Same but for each assigned resource
56
+ self.firstBookedSlots: Dict[Any, int] = {}
57
+ # The index of the last booked time slot
58
+ self.lastBookedSlot: Optional[int] = None
59
+ # Same but for each assigned resource
60
+ self.lastBookedSlots: Dict[Any, int] = {}
61
+
62
+ # First available slot of the resource
63
+ self.minslot: Optional[int] = None
64
+ # Last available slot of the resource
65
+ self.maxslot: Optional[int] = None
66
+
67
+ # Internal effort counter
68
+ self._effort = 0
69
+
70
+ # Track partial slot usage: slot_idx -> seconds_used
71
+ # When a task ends mid-slot, this records how much of the slot was used
72
+ # Subsequent tasks can use the remaining time in that slot
73
+ self.slotSecondsUsed: Dict[int, float] = {}
74
+
75
+ # Track which tasks used which slots and how much
76
+ # slot_idx -> list of (task, seconds_used)
77
+ # This allows multiple tasks to share a slot
78
+ self.slotTaskUsage: Dict[int, list] = {}
79
+
80
+ # Data cache
81
+ self.dCache = DataCache.instance()
82
+
83
+ # Ensure required attributes exist
84
+ required_attrs = [
85
+ 'alloctdeffort', 'chargeset', 'criticalness', 'directreports',
86
+ 'duties', 'efficiency', 'effort', 'limits', 'managers', 'rate',
87
+ 'reports', 'shifts', 'leaves', 'leaveallowances', 'workinghours'
88
+ ]
89
+ for attr in required_attrs:
90
+ try:
91
+ _ = self.property.get(attr, self.scenarioIdx)
92
+ except (ValueError, KeyError, AttributeError):
93
+ pass
94
+
95
+ def prepareScheduling(self) -> None:
96
+ """
97
+ Initialize variables used during the scheduling process.
98
+
99
+ This method must be called at the beginning of each scheduling run.
100
+ """
101
+ self._effort = 0
102
+ if self.property.leaf():
103
+ self.initScoreboard()
104
+
105
+ def initScoreboard(self) -> None:
106
+ """
107
+ Initialize the scoreboard for this resource.
108
+
109
+ The scoreboard tracks the availability and bookings for each time slot.
110
+ """
111
+ start = self.project.attributes.get('start')
112
+ end = self.project.attributes.get('end')
113
+ granularity = self.project.attributes.get('scheduleGranularity', 3600)
114
+
115
+ if not start or not end:
116
+ return
117
+
118
+ self.scoreboard = Scoreboard(start, end, granularity, 2)
119
+ size = self.project.scoreboardSize()
120
+
121
+ # Initialize working hours
122
+ for i in range(size):
123
+ if not self.onShift(i):
124
+ # Mark as non-working time (bit 1 set)
125
+ self.scoreboard[i] = 2
126
+ else:
127
+ self.scoreboard[i] = None
128
+
129
+ # Apply global leaves
130
+ leaves = self.project.attributes.get('leaves', [])
131
+ if leaves:
132
+ for leave in leaves:
133
+ if hasattr(leave, 'interval'):
134
+ start_idx = self.project.dateToIdx(leave.interval.start)
135
+ end_idx = self.project.dateToIdx(leave.interval.end)
136
+ for i in range(start_idx, min(end_idx, size)):
137
+ sb = self.scoreboard[i]
138
+ val = 0 if sb is None else (sb & 2)
139
+ leave_type = leave.type_idx if hasattr(leave, 'type_idx') else 0
140
+ self.scoreboard[i] = val | (leave_type << 2)
141
+
142
+ # Apply resource-specific leaves
143
+ res_leaves = self.property.get('leaves', self.scenarioIdx)
144
+ if res_leaves:
145
+ for leave in res_leaves:
146
+ if hasattr(leave, 'interval'):
147
+ start_idx = self.project.dateToIdx(leave.interval.start)
148
+ end_idx = self.project.dateToIdx(leave.interval.end)
149
+ for i in range(start_idx, min(end_idx, size)):
150
+ sb = self.scoreboard[i]
151
+ if sb is not None:
152
+ leave_idx = (sb & 0x3C) >> 2
153
+ leave_type = leave.type_idx if hasattr(leave, 'type_idx') else 0
154
+ if leave_type > leave_idx:
155
+ self.scoreboard[i] = (sb & 0x2) | (leave_type << 2)
156
+ else:
157
+ leave_type = leave.type_idx if hasattr(leave, 'type_idx') else 0
158
+ self.scoreboard[i] = leave_type << 2
159
+
160
+ def calcCriticalness(self) -> None:
161
+ """
162
+ Calculate the criticalness of the resource.
163
+
164
+ The criticalness is a measure for the probability that all allocations
165
+ can be fulfilled. A value above 1.0 means that statistically some tasks
166
+ will not get their resources.
167
+ """
168
+ if self.scoreboard is None:
169
+ self.property.set_scenario_attr('criticalness', self.scenarioIdx, 0.0)
170
+ else:
171
+ free_slots = sum(1 for slot in self.scoreboard if slot is None)
172
+ allocated_effort = self.property.get('alloctdeffort', self.scenarioIdx) or 0
173
+
174
+ if free_slots == 0:
175
+ self.property.set_scenario_attr('criticalness', self.scenarioIdx, 1.0)
176
+ else:
177
+ self.property.set_scenario_attr('criticalness', self.scenarioIdx,
178
+ allocated_effort / free_slots)
179
+
180
+ def setDirectReports(self) -> None:
181
+ """
182
+ Set up the direct reports relationships based on managers.
183
+ """
184
+ managers = self.property.get('managers', self.scenarioIdx) or []
185
+ new_managers = []
186
+
187
+ for manager_id in managers:
188
+ manager = self.project.resource(manager_id) if isinstance(manager_id, str) else manager_id
189
+
190
+ if manager is None:
191
+ self.error('resource_id_expected',
192
+ f"{manager_id} is not a defined resource.")
193
+ continue
194
+
195
+ if not manager.leaf():
196
+ self.error('manager_is_group',
197
+ f"Resource {self.property.fullId} has group "
198
+ f"{manager.fullId} assigned as manager.")
199
+
200
+ if manager == self.property:
201
+ self.error('manager_is_self',
202
+ f"Resource {self.property.fullId} cannot manage itself.")
203
+
204
+ if self.property.leaf():
205
+ direct_reports = manager.get('directreports', self.scenarioIdx) or []
206
+ if self.property not in direct_reports:
207
+ direct_reports.append(self.property)
208
+
209
+ new_managers.append(manager)
210
+
211
+ # Update managers list with unique entries
212
+ seen = set()
213
+ unique_managers = []
214
+ for m in new_managers:
215
+ if m not in seen:
216
+ unique_managers.append(m)
217
+ seen.add(m)
218
+
219
+ self.property.set_scenario_attr('managers', self.scenarioIdx, unique_managers)
220
+
221
+ def setReports(self) -> None:
222
+ """
223
+ Set up reporting relationships.
224
+ """
225
+ direct_reports = self.property.get('directreports', self.scenarioIdx)
226
+ if not direct_reports:
227
+ return
228
+
229
+ managers = self.property.get('managers', self.scenarioIdx) or []
230
+ for r in managers:
231
+ if hasattr(r, 'setReports_i'):
232
+ r.setReports_i(self.scenarioIdx, [self.property])
233
+
234
+ def preScheduleCheck(self) -> None:
235
+ """
236
+ Pre-schedule validation check.
237
+ """
238
+ pass
239
+
240
+ def finishScheduling(self) -> None:
241
+ """
242
+ Finish scheduling housekeeping.
243
+
244
+ This method is called after scheduling is completed to do housekeeping
245
+ like updating parent resources with duties from children.
246
+ """
247
+ # Recursively descend into all child resources
248
+ for resource in self.property.children:
249
+ resource.finishScheduling(self.scenarioIdx)
250
+
251
+ # Add parent tasks of each task to the duties list
252
+ duties = self.property.get('duties', self.scenarioIdx) or []
253
+ current_duties = list(duties)
254
+ for task in current_duties:
255
+ if hasattr(task, 'ancestors'):
256
+ for p_task in task.ancestors(True):
257
+ if p_task not in duties:
258
+ duties.append(p_task)
259
+
260
+ # Add assigned tasks to parent resource duties
261
+ parents = self.property.parents() if callable(self.property.parents) else self.property.parents
262
+ for p_resource in (parents or []):
263
+ p_duties = p_resource.get('duties', self.scenarioIdx) or []
264
+ for task in duties:
265
+ if task not in p_duties:
266
+ p_duties.append(task)
267
+
268
+ def available(self, sb_idx: int) -> bool:
269
+ """
270
+ Check if resource is available at the given time slot.
271
+
272
+ A slot is available if:
273
+ 1. It's during working hours for this resource
274
+ 2. Not fully booked by another task, OR
275
+ 3. Partially used and has remaining time
276
+
277
+ Args:
278
+ sb_idx: Scoreboard index
279
+
280
+ Returns:
281
+ True if available (fully or partially), False otherwise
282
+ """
283
+ if self.scoreboard is None:
284
+ return False
285
+
286
+ # Check if slot is during working hours for this resource
287
+ if not self.onShift(sb_idx):
288
+ return False
289
+
290
+ # Check if slot has any available time
291
+ available_seconds = self.getAvailableSecondsInSlot(sb_idx)
292
+ if available_seconds <= 0:
293
+ return False
294
+
295
+ # If scoreboard shows a booking but there's available time, it's a partial slot
296
+ # that was released - allow booking
297
+ if self.scoreboard[sb_idx] is not None and available_seconds < self.project.attributes.get('scheduleGranularity', 3600):
298
+ # Partial slot available - allow it
299
+ pass
300
+ elif self.scoreboard[sb_idx] is not None:
301
+ return False
302
+
303
+ limits = self.property.get('limits', self.scenarioIdx)
304
+ if limits and hasattr(limits, 'ok') and not limits.ok(sb_idx):
305
+ return False
306
+
307
+ # Check parent resource limits (hierarchical limit propagation)
308
+ # When a child resource is booked, parent limits must also be checked
309
+ parent = self.property.parent
310
+ while parent:
311
+ parent_limits = parent.get('limits', self.scenarioIdx)
312
+ if parent_limits and hasattr(parent_limits, 'ok') and not parent_limits.ok(sb_idx):
313
+ return False
314
+ parent = parent.parent
315
+
316
+ return True
317
+
318
+ def booked(self, sb_idx: int) -> bool:
319
+ """
320
+ Check if resource is booked at the given time slot.
321
+
322
+ Args:
323
+ sb_idx: Scoreboard index
324
+
325
+ Returns:
326
+ True if booked for a task, False otherwise
327
+ """
328
+ if self.scoreboard is None:
329
+ return False
330
+ # Import here to avoid circular import
331
+ from scriptplan.core.task import Task
332
+ return isinstance(self.scoreboard[sb_idx], Task)
333
+
334
+ def bookedTask(self, sb_idx: int) -> Optional['Task']:
335
+ """
336
+ Get the task booked at the given time slot.
337
+
338
+ Args:
339
+ sb_idx: Scoreboard index
340
+
341
+ Returns:
342
+ The Task or None
343
+ """
344
+ from scriptplan.core.task import Task
345
+ if self.scoreboard is None:
346
+ return None
347
+ sb = self.scoreboard[sb_idx]
348
+ return sb if isinstance(sb, Task) else None
349
+
350
+ def getAvailableSecondsInSlot(self, sb_idx: int) -> float:
351
+ """
352
+ Get the available seconds in a slot, accounting for partial usage.
353
+
354
+ If a previous task ended mid-slot, only the remaining time is available.
355
+
356
+ Args:
357
+ sb_idx: Scoreboard index
358
+
359
+ Returns:
360
+ Available seconds in the slot (0 to slot_duration)
361
+ """
362
+ slot_duration = self.project.attributes.get('scheduleGranularity', 3600)
363
+ seconds_used = self.slotSecondsUsed.get(sb_idx, 0.0)
364
+ return max(0.0, slot_duration - seconds_used)
365
+
366
+ def markSlotPartiallyUsed(self, sb_idx: int, seconds_used: float) -> None:
367
+ """
368
+ Record that a task used only part of a slot.
369
+
370
+ This allows subsequent tasks to use the remaining time.
371
+
372
+ Args:
373
+ sb_idx: Scoreboard index
374
+ seconds_used: Seconds of the slot that were used
375
+ """
376
+ current_used = self.slotSecondsUsed.get(sb_idx, 0.0)
377
+ self.slotSecondsUsed[sb_idx] = current_used + seconds_used
378
+
379
+ def book(self, sb_idx: int, task: 'Task', force: bool = False) -> float:
380
+ """
381
+ Book a time slot for a task.
382
+
383
+ Args:
384
+ sb_idx: Scoreboard index
385
+ task: The task to book
386
+ force: If True, overwrite existing booking
387
+
388
+ Returns:
389
+ Effort gained from this booking (hours), or 0 if booking failed.
390
+ This accounts for partial slot usage.
391
+ """
392
+ if not force and not self.available(sb_idx):
393
+ return 0.0
394
+
395
+ # Make sure task is in duties list
396
+ duties = self.property.get('duties', self.scenarioIdx) or []
397
+ if task not in duties:
398
+ duties.append(task)
399
+
400
+ # Initialize scoreboard if needed
401
+ if self.scoreboard is None:
402
+ self.initScoreboard()
403
+
404
+ # Calculate effort based on available time in slot (for partial slots)
405
+ slot_duration = self.project.attributes.get('scheduleGranularity', 3600)
406
+ available_seconds = self.getAvailableSecondsInSlot(sb_idx)
407
+ efficiency = self.property.get('efficiency', self.scenarioIdx) or 1.0
408
+
409
+ # Effort = (available_seconds / 3600) * efficiency
410
+ effort_gained = (available_seconds / 3600.0) * efficiency
411
+
412
+ # Track effort
413
+ self._effort += effort_gained
414
+
415
+ # Track per-task slot usage for cost calculation
416
+ if sb_idx not in self.slotTaskUsage:
417
+ self.slotTaskUsage[sb_idx] = []
418
+ self.slotTaskUsage[sb_idx].append((task, available_seconds))
419
+
420
+ # Update total seconds used in this slot
421
+ current_used = self.slotSecondsUsed.get(sb_idx, 0.0)
422
+ self.slotSecondsUsed[sb_idx] = current_used + available_seconds
423
+
424
+ # Update scoreboard (may be overwritten if multiple tasks share slot)
425
+ self.scoreboard[sb_idx] = task
426
+
427
+ # Update resource limits
428
+ limits = self.property.get('limits', self.scenarioIdx)
429
+ if limits and hasattr(limits, 'inc'):
430
+ limits.inc(sb_idx)
431
+
432
+ # Propagate to parent resource limits (hierarchical limit propagation)
433
+ # When a child resource is booked, parent limits must also be incremented
434
+ parent = self.property.parent
435
+ while parent:
436
+ parent_limits = parent.get('limits', self.scenarioIdx)
437
+ if parent_limits and hasattr(parent_limits, 'inc'):
438
+ parent_limits.inc(sb_idx)
439
+ parent = parent.parent
440
+
441
+ # Update task limits (including parent task limits)
442
+ task_scenario = task.data[self.scenarioIdx] if hasattr(task, 'data') and task.data else None
443
+ if task_scenario and hasattr(task_scenario, 'incLimits'):
444
+ task_scenario.incLimits(sb_idx, self.property)
445
+
446
+ # Track booked slot ranges
447
+ if self.firstBookedSlot is None or self.firstBookedSlot > sb_idx:
448
+ self.firstBookedSlot = sb_idx
449
+ self.firstBookedSlots[task] = sb_idx
450
+ elif task not in self.firstBookedSlots or self.firstBookedSlots[task] > sb_idx:
451
+ self.firstBookedSlots[task] = sb_idx
452
+
453
+ if self.lastBookedSlot is None or self.lastBookedSlot < sb_idx:
454
+ self.lastBookedSlot = sb_idx
455
+ self.lastBookedSlots[task] = sb_idx
456
+ elif task not in self.lastBookedSlots or self.lastBookedSlots[task] < sb_idx:
457
+ self.lastBookedSlots[task] = sb_idx
458
+
459
+ return effort_gained
460
+
461
+ def releasePartialSlot(self, sb_idx: int, seconds_to_release: float) -> None:
462
+ """
463
+ Release part of a slot back for other tasks to use.
464
+
465
+ Called when a task ends mid-slot to make the remaining time available.
466
+
467
+ Args:
468
+ sb_idx: Scoreboard index
469
+ seconds_to_release: Seconds to release back
470
+ """
471
+ slot_duration = self.project.attributes.get('scheduleGranularity', 3600)
472
+ current_used = self.slotSecondsUsed.get(sb_idx, slot_duration)
473
+ # Reduce the used time, making more available
474
+ self.slotSecondsUsed[sb_idx] = max(0.0, current_used - seconds_to_release)
475
+ # Clear the booking so another task can use it
476
+ if self.scoreboard is not None:
477
+ self.scoreboard[sb_idx] = None
478
+
479
+ def bookedEffort(self) -> float:
480
+ """
481
+ Get the total booked effort for this resource.
482
+
483
+ Returns:
484
+ The effort value
485
+ """
486
+ if self.property.leaf():
487
+ return self._effort
488
+ else:
489
+ effort = 0.0
490
+ for r in self.property.kids():
491
+ if r.data and r.data[self.scenarioIdx]:
492
+ effort += r.data[self.scenarioIdx].bookedEffort()
493
+ return effort
494
+
495
+ def onShift(self, sb_idx: int) -> bool:
496
+ """
497
+ Check if the resource is on shift at the given time slot.
498
+
499
+ Args:
500
+ sb_idx: Scoreboard index
501
+
502
+ Returns:
503
+ True if on shift, False otherwise
504
+ """
505
+ date = self.project.idxToDate(sb_idx)
506
+
507
+ # First check global vacations - they override everything
508
+ vacations = self.project.attributes.get('vacations', [])
509
+ if vacations:
510
+ for vac in vacations:
511
+ if hasattr(vac, 'interval') and vac.interval:
512
+ if vac.interval.start <= date < vac.interval.end:
513
+ return False
514
+
515
+ # Check resource-level leaves/vacations
516
+ leaves = self.property.get('leaves', self.scenarioIdx)
517
+ if leaves:
518
+ for leave in leaves:
519
+ if hasattr(leave, 'interval') and leave.interval:
520
+ if leave.interval.start <= date < leave.interval.end:
521
+ return False
522
+
523
+ # Get resource's timezone for local time conversion
524
+ # Working hours are defined in local time, but slots are in UTC
525
+ resource_tz = self.property.get('timezone', self.scenarioIdx)
526
+
527
+ # Check if resource has a shift reference
528
+ shift = self.property.get('shifts', self.scenarioIdx)
529
+ if shift:
530
+ # Use the shift's working hours
531
+ shift_wh = shift.get('workinghours', self.scenarioIdx)
532
+ if shift_wh and hasattr(shift_wh, 'onShift'):
533
+ return shift_wh.onShift(sb_idx, timezone=resource_tz)
534
+
535
+ # Check if resource has direct working hours
536
+ workinghours = self.property.get('workinghours', self.scenarioIdx)
537
+ if workinghours and hasattr(workinghours, 'onShift'):
538
+ return workinghours.onShift(sb_idx, timezone=resource_tz)
539
+
540
+ # Default: use project's working time
541
+ return self.project.isWorkingTime(sb_idx)
542
+
543
+ def setReports_i(self, reports: List) -> None:
544
+ """
545
+ Internal method to set reports relationship.
546
+
547
+ Args:
548
+ reports: List of resources reporting to this one
549
+ """
550
+ if self.property in reports:
551
+ self.error('manager_loop',
552
+ f"Management loop detected. {self.property.fullId} "
553
+ "has self in list of reports")
554
+
555
+ current_reports = self.property.get('reports', self.scenarioIdx) or []
556
+ for r in reports:
557
+ if r not in current_reports:
558
+ current_reports.append(r)
559
+
560
+ managers = self.property.get('managers', self.scenarioIdx) or []
561
+ for r in managers:
562
+ if hasattr(r, 'setReports_i'):
563
+ r.setReports_i(self.scenarioIdx, current_reports)
564
+
565
+ def treeSum(self, start_idx: int, end_idx: int, *args,
566
+ block: Callable[['ResourceScenario'], float]) -> float:
567
+ """
568
+ Generic tree iterator that recursively accumulates results.
569
+
570
+ Args:
571
+ start_idx: Start scoreboard index
572
+ end_idx: End scoreboard index
573
+ *args: Additional arguments
574
+ block: Callable to execute on leaf nodes
575
+
576
+ Returns:
577
+ Accumulated sum
578
+ """
579
+ cache_tag = "treeSum"
580
+ return self.treeSumR(cache_tag, start_idx, end_idx, *args, block=block)
581
+
582
+ def treeSumR(self, cache_tag: str, start_idx: int, end_idx: int, *args,
583
+ block: Callable[['ResourceScenario'], float]) -> float:
584
+ """
585
+ Recursive implementation of treeSum.
586
+
587
+ Args:
588
+ cache_tag: Cache key tag
589
+ start_idx: Start scoreboard index
590
+ end_idx: End scoreboard index
591
+ *args: Additional arguments
592
+ block: Callable to execute on leaf nodes
593
+
594
+ Returns:
595
+ Accumulated sum
596
+ """
597
+ if self.property.container():
598
+ sum_val = 0.0
599
+ for resource in self.property.kids():
600
+ if resource.data and resource.data[self.scenarioIdx]:
601
+ res_scenario = resource.data[self.scenarioIdx]
602
+ sum_val += res_scenario.treeSumR(cache_tag, start_idx, end_idx,
603
+ *args, block=block)
604
+ return sum_val
605
+ else:
606
+ return block(self)
607
+
608
+ def getEffectiveWork(self, start_idx: int, end_idx: int,
609
+ task: Optional['Task'] = None) -> float:
610
+ """
611
+ Get the effective work done by this resource.
612
+
613
+ Args:
614
+ start_idx: Start scoreboard index
615
+ end_idx: End scoreboard index
616
+ task: Optional task filter
617
+
618
+ Returns:
619
+ Work in daily load units
620
+ """
621
+ duties = self.property.get('duties', self.scenarioIdx) or []
622
+ if start_idx >= end_idx or (task and task not in duties):
623
+ return 0.0
624
+
625
+ def calculate(res_scen: 'ResourceScenario') -> float:
626
+ if res_scen.scoreboard is None:
627
+ return 0.0
628
+ allocated = res_scen.getAllocatedSlots(start_idx, end_idx, task)
629
+ granularity = res_scen.project.attributes.get('scheduleGranularity', 3600)
630
+ efficiency = res_scen.property.get('efficiency', res_scen.scenarioIdx) or 1.0
631
+ return res_scen.project.convertToDailyLoad(allocated * granularity) * efficiency
632
+
633
+ return self.treeSum(start_idx, end_idx, task, block=calculate)
634
+
635
+ def getAllocatedSlots(self, start_idx: int, end_idx: int,
636
+ task: Optional['Task'] = None) -> int:
637
+ """
638
+ Count booked slots in the given range.
639
+
640
+ Args:
641
+ start_idx: Start scoreboard index
642
+ end_idx: End scoreboard index
643
+ task: Optional task filter
644
+
645
+ Returns:
646
+ Number of allocated slots
647
+ """
648
+ if self.scoreboard is None:
649
+ return 0
650
+
651
+ if start_idx >= end_idx:
652
+ return 0
653
+
654
+ from scriptplan.core.task import Task
655
+
656
+ booked_slots = 0
657
+ task_list = task.all() if task and hasattr(task, 'all') else []
658
+
659
+ actual_end = min(end_idx, len(self.scoreboard))
660
+ for i in range(start_idx, actual_end):
661
+ slot = self.scoreboard[i]
662
+ if isinstance(slot, Task):
663
+ if task is None or slot in task_list or slot == task:
664
+ booked_slots += 1
665
+
666
+ return booked_slots
667
+
668
+ def getFreeSlots(self, start_idx: int, end_idx: int) -> int:
669
+ """
670
+ Count free slots in the given range.
671
+
672
+ Args:
673
+ start_idx: Start scoreboard index
674
+ end_idx: End scoreboard index
675
+
676
+ Returns:
677
+ Number of free slots
678
+ """
679
+ if self.scoreboard is None:
680
+ return 0
681
+
682
+ count = 0
683
+ actual_end = min(end_idx, len(self.scoreboard))
684
+ for i in range(start_idx, actual_end):
685
+ if self.scoreboard[i] is None:
686
+ count += 1
687
+ return count
688
+
689
+ def getWorkSlots(self, start_idx: int, end_idx: int) -> int:
690
+ """
691
+ Count work slots (free + allocated) in the given range.
692
+
693
+ Args:
694
+ start_idx: Start scoreboard index
695
+ end_idx: End scoreboard index
696
+
697
+ Returns:
698
+ Number of work slots
699
+ """
700
+ from scriptplan.core.task import Task
701
+
702
+ if self.scoreboard is None:
703
+ return 0
704
+
705
+ count = 0
706
+ actual_end = min(end_idx, len(self.scoreboard))
707
+ for i in range(start_idx, actual_end):
708
+ slot = self.scoreboard[i]
709
+ if slot is None or isinstance(slot, Task):
710
+ count += 1
711
+ return count