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.
- scriptplan/__init__.py +22 -0
- scriptplan/cli/__init__.py +7 -0
- scriptplan/cli/main.py +546 -0
- scriptplan/core/__init__.py +0 -0
- scriptplan/core/account.py +125 -0
- scriptplan/core/allocation.py +69 -0
- scriptplan/core/booking.py +39 -0
- scriptplan/core/journal.py +377 -0
- scriptplan/core/leave.py +14 -0
- scriptplan/core/limits.py +354 -0
- scriptplan/core/project.py +924 -0
- scriptplan/core/property.py +1290 -0
- scriptplan/core/resource.py +198 -0
- scriptplan/core/resource_scenario.py +711 -0
- scriptplan/core/scenario.py +5 -0
- scriptplan/core/scenario_data.py +39 -0
- scriptplan/core/shift.py +71 -0
- scriptplan/core/task.py +77 -0
- scriptplan/core/task_scenario.py +1515 -0
- scriptplan/core/timesheet.py +457 -0
- scriptplan/core/working_hours.py +231 -0
- scriptplan/parser/__init__.py +0 -0
- scriptplan/parser/macro_processor.py +264 -0
- scriptplan/parser/tjp.lark +412 -0
- scriptplan/parser/tjp_parser.py +1904 -0
- scriptplan/py.typed +0 -0
- scriptplan/report/__init__.py +75 -0
- scriptplan/report/html_generator.py +477 -0
- scriptplan/report/report.py +466 -0
- scriptplan/report/report_base.py +397 -0
- scriptplan/report/report_context.py +248 -0
- scriptplan/report/resource_report.py +341 -0
- scriptplan/report/table_report.py +693 -0
- scriptplan/report/task_report.py +362 -0
- scriptplan/report/text_report.py +172 -0
- scriptplan/scheduler/__init__.py +0 -0
- scriptplan/scheduler/batch_processor.py +238 -0
- scriptplan/scheduler/scoreboard.py +120 -0
- scriptplan/utils/__init__.py +0 -0
- scriptplan/utils/data_cache.py +46 -0
- scriptplan/utils/logger.py +243 -0
- scriptplan/utils/message_handler.py +515 -0
- scriptplan/utils/time.py +195 -0
- scriptplan-0.9.0.dist-info/METADATA +161 -0
- scriptplan-0.9.0.dist-info/RECORD +49 -0
- scriptplan-0.9.0.dist-info/WHEEL +5 -0
- scriptplan-0.9.0.dist-info/entry_points.txt +2 -0
- scriptplan-0.9.0.dist-info/licenses/LICENSE +201 -0
- 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
|