rhylthyme 0.1.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.
- rhylthyme/__init__.py +22 -0
- rhylthyme/auto_planner.py +618 -0
- rhylthyme/cli/cli.py +52 -0
- rhylthyme/cli.py +309 -0
- rhylthyme/dag_visualizer_backup.py +2426 -0
- rhylthyme/environment_icons.py +258 -0
- rhylthyme/environment_loader.py +334 -0
- rhylthyme/environment_schemas.py +443 -0
- rhylthyme/main.py +39 -0
- rhylthyme/marimo_runner.py +553 -0
- rhylthyme/program_planner.py +648 -0
- rhylthyme/program_runner.py +2291 -0
- rhylthyme/resource_cli.py +356 -0
- rhylthyme/resource_specs.py +627 -0
- rhylthyme/validate_all_schemas.py +171 -0
- rhylthyme/validate_environment_consistency.py +284 -0
- rhylthyme/validate_program.py +457 -0
- rhylthyme/web_visualizer.py +5202 -0
- rhylthyme-0.1.0.dist-info/METADATA +330 -0
- rhylthyme-0.1.0.dist-info/RECORD +37 -0
- rhylthyme-0.1.0.dist-info/WHEEL +5 -0
- rhylthyme-0.1.0.dist-info/entry_points.txt +2 -0
- rhylthyme-0.1.0.dist-info/licenses/LICENSE +190 -0
- rhylthyme-0.1.0.dist-info/top_level.txt +2 -0
- scripts/app.py +650 -0
- scripts/app_old.py +659 -0
- scripts/fix_all_examples.py +117 -0
- scripts/fix_examples_to_schema.py +143 -0
- scripts/open_app.py +11 -0
- scripts/rhylthyme-cli.py +18 -0
- scripts/rhylthyme-dag.py +25 -0
- scripts/rhylthyme.py +89 -0
- scripts/test_all_examples.py +177 -0
- scripts/test_multi_resource.py +60 -0
- scripts/test_planner_fixes.py +136 -0
- scripts/update_template.py +31 -0
- scripts/validate_all_examples_ci.py +95 -0
rhylthyme/__init__.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Rhylthyme - A tool for working with real-time program schedules
|
|
3
|
+
|
|
4
|
+
This package provides tools for defining, validating, and executing
|
|
5
|
+
real-time program schedules with tracks, steps, and resource constraints.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
__version__ = "0.1.0"
|
|
9
|
+
|
|
10
|
+
from .validate_program import validate_program, perform_additional_validations, validate_program_file, load_program_file
|
|
11
|
+
from .program_runner import run_program
|
|
12
|
+
from .marimo_runner import create_marimo_runner, MarimoRunner
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
'validate_program',
|
|
16
|
+
'perform_additional_validations',
|
|
17
|
+
'validate_program_file',
|
|
18
|
+
'load_program_file',
|
|
19
|
+
'run_program',
|
|
20
|
+
'create_marimo_runner',
|
|
21
|
+
'MarimoRunner'
|
|
22
|
+
]
|
|
@@ -0,0 +1,618 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Auto-planner for Rhylthyme programs.
|
|
4
|
+
|
|
5
|
+
This module implements automatic timeline planning with various strategies:
|
|
6
|
+
- Synchronized finish: All tracks finish around the same time
|
|
7
|
+
- Cover: Minimize dead time/pauses between activities
|
|
8
|
+
- Minimize overall length: Shortest total duration
|
|
9
|
+
|
|
10
|
+
Features:
|
|
11
|
+
- Respects track priorities
|
|
12
|
+
- Avoids overutilization of resources
|
|
13
|
+
- Handles flexible (flex) steps that can expand to fill gaps
|
|
14
|
+
- Considers step min/max duration limits
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import copy
|
|
18
|
+
import json
|
|
19
|
+
from typing import Dict, List, Any, Tuple, Optional, Set
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from enum import Enum
|
|
22
|
+
|
|
23
|
+
class PlanningStrategy(Enum):
|
|
24
|
+
SYNCHRONIZED_FINISH = "synchronized_finish"
|
|
25
|
+
COVER = "cover"
|
|
26
|
+
MINIMIZE_LENGTH = "minimize_length"
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class ResourceConstraint:
|
|
30
|
+
name: str
|
|
31
|
+
capacity: float = 1.0
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class ResourceUsage:
|
|
35
|
+
resource: str
|
|
36
|
+
start_time: float
|
|
37
|
+
end_time: float
|
|
38
|
+
fraction: float = 1.0
|
|
39
|
+
step_id: str = ""
|
|
40
|
+
track_id: str = ""
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class StepTiming:
|
|
44
|
+
step_id: str
|
|
45
|
+
track_id: str
|
|
46
|
+
start_time: float
|
|
47
|
+
duration: float
|
|
48
|
+
end_time: float
|
|
49
|
+
is_flex: bool = False
|
|
50
|
+
min_duration: float = 0
|
|
51
|
+
max_duration: float = float('inf')
|
|
52
|
+
priority: int = 100
|
|
53
|
+
resources: List[str] = None
|
|
54
|
+
|
|
55
|
+
def __post_init__(self):
|
|
56
|
+
if self.resources is None:
|
|
57
|
+
self.resources = []
|
|
58
|
+
self.end_time = self.start_time + self.duration
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class TrackInfo:
|
|
62
|
+
track_id: str
|
|
63
|
+
name: str
|
|
64
|
+
priority: int = 100
|
|
65
|
+
steps: List[Dict] = None
|
|
66
|
+
|
|
67
|
+
def __post_init__(self):
|
|
68
|
+
if self.steps is None:
|
|
69
|
+
self.steps = []
|
|
70
|
+
|
|
71
|
+
class AutoPlanner:
|
|
72
|
+
"""
|
|
73
|
+
Auto-planner that implements various planning strategies for Rhylthyme programs.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
def __init__(self, program: Dict[str, Any], resource_constraints: List[Dict[str, Any]] = None):
|
|
77
|
+
self.program = program
|
|
78
|
+
self.resource_constraints = self._parse_resource_constraints(resource_constraints or [])
|
|
79
|
+
self.tracks = self._parse_tracks()
|
|
80
|
+
self.steps = self._parse_steps()
|
|
81
|
+
|
|
82
|
+
def _parse_resource_constraints(self, constraints: List[Dict[str, Any]]) -> Dict[str, ResourceConstraint]:
|
|
83
|
+
"""Parse resource constraints from the program."""
|
|
84
|
+
result = {}
|
|
85
|
+
for constraint in constraints:
|
|
86
|
+
name = constraint.get('name', '')
|
|
87
|
+
capacity = constraint.get('capacity', 1.0)
|
|
88
|
+
result[name] = ResourceConstraint(name=name, capacity=capacity)
|
|
89
|
+
return result
|
|
90
|
+
|
|
91
|
+
def _parse_tracks(self) -> Dict[str, TrackInfo]:
|
|
92
|
+
"""Parse track information including priorities."""
|
|
93
|
+
tracks = {}
|
|
94
|
+
for track_data in self.program.get('tracks', []):
|
|
95
|
+
track_id = track_data.get('trackId', '')
|
|
96
|
+
name = track_data.get('name', '')
|
|
97
|
+
priority = track_data.get('priority', 100)
|
|
98
|
+
steps = track_data.get('steps', [])
|
|
99
|
+
|
|
100
|
+
tracks[track_id] = TrackInfo(
|
|
101
|
+
track_id=track_id,
|
|
102
|
+
name=name,
|
|
103
|
+
priority=priority,
|
|
104
|
+
steps=steps
|
|
105
|
+
)
|
|
106
|
+
return tracks
|
|
107
|
+
|
|
108
|
+
def _parse_steps(self) -> Dict[str, Dict]:
|
|
109
|
+
"""Parse all steps from all tracks."""
|
|
110
|
+
steps = {}
|
|
111
|
+
for track in self.program.get('tracks', []):
|
|
112
|
+
track_id = track.get('trackId', '')
|
|
113
|
+
for step in track.get('steps', []):
|
|
114
|
+
step_id = step.get('stepId', '')
|
|
115
|
+
step_copy = copy.deepcopy(step)
|
|
116
|
+
step_copy['trackId'] = track_id
|
|
117
|
+
steps[step_id] = step_copy
|
|
118
|
+
return steps
|
|
119
|
+
|
|
120
|
+
def _parse_duration(self, duration_spec: Any) -> Tuple[float, float, float]:
|
|
121
|
+
"""
|
|
122
|
+
Parse duration specification into (min, optimal, max) seconds.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
duration_spec: Duration specification from step
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
Tuple of (min_duration, optimal_duration, max_duration) in seconds
|
|
129
|
+
"""
|
|
130
|
+
if isinstance(duration_spec, (int, float)):
|
|
131
|
+
return (duration_spec, duration_spec, duration_spec)
|
|
132
|
+
|
|
133
|
+
if isinstance(duration_spec, dict):
|
|
134
|
+
duration_type = duration_spec.get('type', 'fixed')
|
|
135
|
+
|
|
136
|
+
if duration_type == 'fixed':
|
|
137
|
+
seconds = self._parse_time_string(duration_spec.get('seconds', 0))
|
|
138
|
+
return (seconds, seconds, seconds)
|
|
139
|
+
|
|
140
|
+
elif duration_type == 'variable':
|
|
141
|
+
min_sec = self._parse_time_string(duration_spec.get('minSeconds', 0))
|
|
142
|
+
max_sec = self._parse_time_string(duration_spec.get('maxSeconds', min_sec))
|
|
143
|
+
default_sec = self._parse_time_string(duration_spec.get('defaultSeconds', (min_sec + max_sec) / 2))
|
|
144
|
+
optimal_sec = self._parse_time_string(duration_spec.get('optimalSeconds', default_sec))
|
|
145
|
+
return (min_sec, optimal_sec, max_sec)
|
|
146
|
+
|
|
147
|
+
elif duration_type == 'indefinite':
|
|
148
|
+
return (0, 3600, float('inf')) # Default 1 hour optimal for indefinite
|
|
149
|
+
|
|
150
|
+
return (0, 0, 0)
|
|
151
|
+
|
|
152
|
+
def _parse_time_string(self, time_str: Any) -> float:
|
|
153
|
+
"""Parse time string like '30s', '2m', '1h' into seconds."""
|
|
154
|
+
if isinstance(time_str, (int, float)):
|
|
155
|
+
return float(time_str)
|
|
156
|
+
|
|
157
|
+
if not isinstance(time_str, str):
|
|
158
|
+
return 0.0
|
|
159
|
+
|
|
160
|
+
time_str = str(time_str).strip().lower()
|
|
161
|
+
|
|
162
|
+
if time_str.endswith('s'):
|
|
163
|
+
return float(time_str[:-1])
|
|
164
|
+
elif time_str.endswith('m'):
|
|
165
|
+
return float(time_str[:-1]) * 60
|
|
166
|
+
elif time_str.endswith('h'):
|
|
167
|
+
return float(time_str[:-1]) * 3600
|
|
168
|
+
else:
|
|
169
|
+
try:
|
|
170
|
+
return float(time_str)
|
|
171
|
+
except ValueError:
|
|
172
|
+
return 0.0
|
|
173
|
+
|
|
174
|
+
def _get_step_resources(self, step: Dict[str, Any]) -> List[str]:
|
|
175
|
+
"""Extract resource requirements from a step."""
|
|
176
|
+
resources = []
|
|
177
|
+
|
|
178
|
+
# Single task
|
|
179
|
+
if 'task' in step:
|
|
180
|
+
resources.append(step['task'])
|
|
181
|
+
|
|
182
|
+
# Multiple tasks
|
|
183
|
+
if 'tasks' in step:
|
|
184
|
+
resources.extend(step['tasks'])
|
|
185
|
+
|
|
186
|
+
# Task resources with fractions
|
|
187
|
+
if 'taskResources' in step:
|
|
188
|
+
for res in step['taskResources']:
|
|
189
|
+
resources.append(res.get('name', ''))
|
|
190
|
+
|
|
191
|
+
return [r for r in resources if r] # Remove empty strings
|
|
192
|
+
|
|
193
|
+
def _build_dependency_graph(self) -> Dict[str, List[str]]:
|
|
194
|
+
"""Build dependency graph: step_id -> [prerequisite_step_ids]."""
|
|
195
|
+
dependencies = {}
|
|
196
|
+
|
|
197
|
+
for step_id, step in self.steps.items():
|
|
198
|
+
dependencies[step_id] = []
|
|
199
|
+
|
|
200
|
+
start_trigger = step.get('trigger', {})
|
|
201
|
+
deps = self._extract_dependencies_from_trigger(start_trigger)
|
|
202
|
+
dependencies[step_id].extend(deps)
|
|
203
|
+
|
|
204
|
+
return dependencies
|
|
205
|
+
|
|
206
|
+
def _extract_dependencies_from_trigger(self, trigger: Dict[str, Any]) -> List[str]:
|
|
207
|
+
"""Extract step dependencies from a start trigger."""
|
|
208
|
+
deps = []
|
|
209
|
+
|
|
210
|
+
# If no type is specified but stepId or on is present, default to afterStep
|
|
211
|
+
trigger_type = trigger.get('type')
|
|
212
|
+
if not trigger_type and (trigger.get('stepId') or trigger.get('on')):
|
|
213
|
+
trigger_type = 'afterStep'
|
|
214
|
+
|
|
215
|
+
if trigger_type in ['afterStep', 'afterStepWithBuffer']:
|
|
216
|
+
step_id = trigger.get('stepId') or trigger.get('on')
|
|
217
|
+
if step_id:
|
|
218
|
+
deps.append(step_id)
|
|
219
|
+
|
|
220
|
+
elif trigger.get('logic'): # Multiple triggers
|
|
221
|
+
for sub_trigger in trigger.get('triggers', []):
|
|
222
|
+
deps.extend(self._extract_dependencies_from_trigger(sub_trigger))
|
|
223
|
+
|
|
224
|
+
return deps
|
|
225
|
+
|
|
226
|
+
def _calculate_earliest_start_times(self, dependencies: Dict[str, List[str]]) -> Dict[str, float]:
|
|
227
|
+
"""Calculate earliest possible start time for each step."""
|
|
228
|
+
earliest_times = {}
|
|
229
|
+
|
|
230
|
+
def calculate_earliest(step_id: str, visited: Set[str] = None) -> float:
|
|
231
|
+
if visited is None:
|
|
232
|
+
visited = set()
|
|
233
|
+
|
|
234
|
+
if step_id in visited:
|
|
235
|
+
return 0 # Cycle detected
|
|
236
|
+
|
|
237
|
+
if step_id in earliest_times:
|
|
238
|
+
return earliest_times[step_id]
|
|
239
|
+
|
|
240
|
+
visited.add(step_id)
|
|
241
|
+
|
|
242
|
+
max_predecessor_end = 0.0
|
|
243
|
+
|
|
244
|
+
for dep_step_id in dependencies.get(step_id, []):
|
|
245
|
+
if dep_step_id in self.steps:
|
|
246
|
+
dep_start = calculate_earliest(dep_step_id, visited.copy())
|
|
247
|
+
_, dep_duration, _ = self._parse_duration(self.steps[dep_step_id].get('duration', 0))
|
|
248
|
+
dep_end = dep_start + dep_duration
|
|
249
|
+
max_predecessor_end = max(max_predecessor_end, dep_end)
|
|
250
|
+
|
|
251
|
+
earliest_times[step_id] = max_predecessor_end
|
|
252
|
+
return max_predecessor_end
|
|
253
|
+
|
|
254
|
+
for step_id in self.steps:
|
|
255
|
+
calculate_earliest(step_id)
|
|
256
|
+
|
|
257
|
+
return earliest_times
|
|
258
|
+
|
|
259
|
+
def _check_resource_overutilization(self, step_timings: List[StepTiming]) -> List[Tuple[str, float, float]]:
|
|
260
|
+
"""
|
|
261
|
+
Check for resource overutilization in the current plan.
|
|
262
|
+
|
|
263
|
+
Returns:
|
|
264
|
+
List of (resource_name, start_time, end_time) tuples for overutilized periods
|
|
265
|
+
"""
|
|
266
|
+
overutilizations = []
|
|
267
|
+
|
|
268
|
+
# Group resource usage by resource
|
|
269
|
+
resource_usage = {}
|
|
270
|
+
for timing in step_timings:
|
|
271
|
+
for resource in timing.resources:
|
|
272
|
+
if resource not in resource_usage:
|
|
273
|
+
resource_usage[resource] = []
|
|
274
|
+
|
|
275
|
+
resource_usage[resource].append(ResourceUsage(
|
|
276
|
+
resource=resource,
|
|
277
|
+
start_time=timing.start_time,
|
|
278
|
+
end_time=timing.end_time,
|
|
279
|
+
step_id=timing.step_id,
|
|
280
|
+
track_id=timing.track_id
|
|
281
|
+
))
|
|
282
|
+
|
|
283
|
+
# Check each resource for overutilization
|
|
284
|
+
for resource_name, usages in resource_usage.items():
|
|
285
|
+
constraint = self.resource_constraints.get(resource_name)
|
|
286
|
+
if not constraint:
|
|
287
|
+
continue # No constraint defined
|
|
288
|
+
|
|
289
|
+
# Sort usages by start time
|
|
290
|
+
usages.sort(key=lambda x: x.start_time)
|
|
291
|
+
|
|
292
|
+
# Find overlapping periods
|
|
293
|
+
for i, usage1 in enumerate(usages):
|
|
294
|
+
concurrent_count = 1
|
|
295
|
+
for j in range(i + 1, len(usages)):
|
|
296
|
+
usage2 = usages[j]
|
|
297
|
+
|
|
298
|
+
# Check if they overlap
|
|
299
|
+
if usage2.start_time < usage1.end_time:
|
|
300
|
+
concurrent_count += 1
|
|
301
|
+
else:
|
|
302
|
+
break
|
|
303
|
+
|
|
304
|
+
if concurrent_count > constraint.capacity:
|
|
305
|
+
overutilizations.append((resource_name, usage1.start_time, usage1.end_time))
|
|
306
|
+
|
|
307
|
+
return overutilizations
|
|
308
|
+
|
|
309
|
+
def _apply_flex_adjustments(self, step_timings: List[StepTiming]) -> List[StepTiming]:
|
|
310
|
+
"""Apply flex adjustments to fill gaps between steps."""
|
|
311
|
+
# Group timings by track
|
|
312
|
+
track_timings = {}
|
|
313
|
+
for timing in step_timings:
|
|
314
|
+
if timing.track_id not in track_timings:
|
|
315
|
+
track_timings[timing.track_id] = []
|
|
316
|
+
track_timings[timing.track_id].append(timing)
|
|
317
|
+
|
|
318
|
+
# Process each track
|
|
319
|
+
adjusted_timings = []
|
|
320
|
+
for track_id, timings in track_timings.items():
|
|
321
|
+
# Sort by start time
|
|
322
|
+
timings.sort(key=lambda x: x.start_time)
|
|
323
|
+
|
|
324
|
+
for i, timing in enumerate(timings):
|
|
325
|
+
step = self.steps.get(timing.step_id, {})
|
|
326
|
+
flex_config = step.get('flex', {})
|
|
327
|
+
|
|
328
|
+
if not flex_config.get('enabled', False):
|
|
329
|
+
adjusted_timings.append(timing)
|
|
330
|
+
continue
|
|
331
|
+
|
|
332
|
+
# Find next step in the same track
|
|
333
|
+
next_step_start = None
|
|
334
|
+
if i + 1 < len(timings):
|
|
335
|
+
next_step_start = timings[i + 1].start_time
|
|
336
|
+
|
|
337
|
+
# Calculate flex limits
|
|
338
|
+
min_dur = timing.min_duration
|
|
339
|
+
max_dur = timing.max_duration
|
|
340
|
+
|
|
341
|
+
if flex_config.get('minDuration'):
|
|
342
|
+
min_dur = max(min_dur, self._parse_time_string(flex_config['minDuration']))
|
|
343
|
+
|
|
344
|
+
if flex_config.get('maxDuration'):
|
|
345
|
+
max_dur = min(max_dur, self._parse_time_string(flex_config['maxDuration']))
|
|
346
|
+
|
|
347
|
+
# Apply flex based on mode
|
|
348
|
+
flex_mode = flex_config.get('mode', 'fill')
|
|
349
|
+
new_duration = timing.duration
|
|
350
|
+
|
|
351
|
+
if flex_mode == 'fill' and next_step_start is not None:
|
|
352
|
+
# Fill gap to next step
|
|
353
|
+
available_time = next_step_start - timing.start_time
|
|
354
|
+
new_duration = min(max_dur, max(min_dur, available_time))
|
|
355
|
+
|
|
356
|
+
elif flex_mode == 'max':
|
|
357
|
+
# Extend to maximum duration
|
|
358
|
+
new_duration = max_dur
|
|
359
|
+
|
|
360
|
+
# Update timing
|
|
361
|
+
adjusted_timing = copy.deepcopy(timing)
|
|
362
|
+
adjusted_timing.duration = new_duration
|
|
363
|
+
adjusted_timing.end_time = adjusted_timing.start_time + new_duration
|
|
364
|
+
adjusted_timings.append(adjusted_timing)
|
|
365
|
+
|
|
366
|
+
return adjusted_timings
|
|
367
|
+
|
|
368
|
+
def plan_synchronized_finish(self) -> List[StepTiming]:
|
|
369
|
+
"""
|
|
370
|
+
Plan timeline with synchronized finish strategy.
|
|
371
|
+
All tracks should finish around the same time.
|
|
372
|
+
"""
|
|
373
|
+
dependencies = self._build_dependency_graph()
|
|
374
|
+
earliest_times = self._calculate_earliest_start_times(dependencies)
|
|
375
|
+
|
|
376
|
+
# Calculate track durations at earliest start times
|
|
377
|
+
track_durations = {}
|
|
378
|
+
step_timings = []
|
|
379
|
+
|
|
380
|
+
for track_id, track_info in self.tracks.items():
|
|
381
|
+
track_duration = 0
|
|
382
|
+
track_steps = []
|
|
383
|
+
|
|
384
|
+
for step in track_info.steps:
|
|
385
|
+
step_id = step.get('stepId', '')
|
|
386
|
+
start_time = earliest_times.get(step_id, 0)
|
|
387
|
+
min_dur, opt_dur, max_dur = self._parse_duration(step.get('duration', 0))
|
|
388
|
+
|
|
389
|
+
step_timing = StepTiming(
|
|
390
|
+
step_id=step_id,
|
|
391
|
+
track_id=track_id,
|
|
392
|
+
start_time=start_time,
|
|
393
|
+
duration=opt_dur,
|
|
394
|
+
end_time=start_time + opt_dur,
|
|
395
|
+
min_duration=min_dur,
|
|
396
|
+
max_duration=max_dur,
|
|
397
|
+
priority=step.get('priority', 100),
|
|
398
|
+
resources=self._get_step_resources(step)
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
track_steps.append(step_timing)
|
|
402
|
+
track_duration = max(track_duration, step_timing.end_time)
|
|
403
|
+
|
|
404
|
+
track_durations[track_id] = track_duration
|
|
405
|
+
step_timings.extend(track_steps)
|
|
406
|
+
|
|
407
|
+
# Find the longest track
|
|
408
|
+
max_duration = max(track_durations.values()) if track_durations else 0
|
|
409
|
+
|
|
410
|
+
# Delay shorter tracks to finish at the same time
|
|
411
|
+
for track_id, duration in track_durations.items():
|
|
412
|
+
if duration < max_duration:
|
|
413
|
+
delay = max_duration - duration
|
|
414
|
+
|
|
415
|
+
# Apply delay to all steps in this track
|
|
416
|
+
for timing in step_timings:
|
|
417
|
+
if timing.track_id == track_id:
|
|
418
|
+
timing.start_time += delay
|
|
419
|
+
timing.end_time += delay
|
|
420
|
+
|
|
421
|
+
# Apply flex adjustments
|
|
422
|
+
step_timings = self._apply_flex_adjustments(step_timings)
|
|
423
|
+
|
|
424
|
+
return step_timings
|
|
425
|
+
|
|
426
|
+
def plan_cover(self) -> List[StepTiming]:
|
|
427
|
+
"""
|
|
428
|
+
Plan timeline with cover strategy.
|
|
429
|
+
Minimize dead time/pauses between activities.
|
|
430
|
+
"""
|
|
431
|
+
dependencies = self._build_dependency_graph()
|
|
432
|
+
earliest_times = self._calculate_earliest_start_times(dependencies)
|
|
433
|
+
|
|
434
|
+
step_timings = []
|
|
435
|
+
|
|
436
|
+
# Sort tracks by priority (higher priority = lower number)
|
|
437
|
+
sorted_tracks = sorted(self.tracks.items(), key=lambda x: x[1].priority)
|
|
438
|
+
|
|
439
|
+
for track_id, track_info in sorted_tracks:
|
|
440
|
+
for step in track_info.steps:
|
|
441
|
+
step_id = step.get('stepId', '')
|
|
442
|
+
start_time = earliest_times.get(step_id, 0)
|
|
443
|
+
min_dur, opt_dur, max_dur = self._parse_duration(step.get('duration', 0))
|
|
444
|
+
|
|
445
|
+
step_timing = StepTiming(
|
|
446
|
+
step_id=step_id,
|
|
447
|
+
track_id=track_id,
|
|
448
|
+
start_time=start_time,
|
|
449
|
+
duration=opt_dur,
|
|
450
|
+
end_time=start_time + opt_dur,
|
|
451
|
+
min_duration=min_dur,
|
|
452
|
+
max_duration=max_dur,
|
|
453
|
+
priority=step.get('priority', 100),
|
|
454
|
+
resources=self._get_step_resources(step)
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
step_timings.append(step_timing)
|
|
458
|
+
|
|
459
|
+
# Apply flex adjustments to minimize gaps
|
|
460
|
+
step_timings = self._apply_flex_adjustments(step_timings)
|
|
461
|
+
|
|
462
|
+
return step_timings
|
|
463
|
+
|
|
464
|
+
def plan_minimize_length(self) -> List[StepTiming]:
|
|
465
|
+
"""
|
|
466
|
+
Plan timeline with minimize overall length strategy.
|
|
467
|
+
Shortest total duration while respecting dependencies.
|
|
468
|
+
"""
|
|
469
|
+
dependencies = self._build_dependency_graph()
|
|
470
|
+
earliest_times = self._calculate_earliest_start_times(dependencies)
|
|
471
|
+
|
|
472
|
+
step_timings = []
|
|
473
|
+
|
|
474
|
+
for step_id, step in self.steps.items():
|
|
475
|
+
start_time = earliest_times.get(step_id, 0)
|
|
476
|
+
min_dur, opt_dur, max_dur = self._parse_duration(step.get('duration', 0))
|
|
477
|
+
|
|
478
|
+
# Use minimum duration for shortest timeline
|
|
479
|
+
duration = min_dur if min_dur > 0 else opt_dur
|
|
480
|
+
|
|
481
|
+
step_timing = StepTiming(
|
|
482
|
+
step_id=step_id,
|
|
483
|
+
track_id=step.get('trackId', ''),
|
|
484
|
+
start_time=start_time,
|
|
485
|
+
duration=duration,
|
|
486
|
+
end_time=start_time + duration,
|
|
487
|
+
min_duration=min_dur,
|
|
488
|
+
max_duration=max_dur,
|
|
489
|
+
priority=step.get('priority', 100),
|
|
490
|
+
resources=self._get_step_resources(step)
|
|
491
|
+
)
|
|
492
|
+
|
|
493
|
+
step_timings.append(step_timing)
|
|
494
|
+
|
|
495
|
+
return step_timings
|
|
496
|
+
|
|
497
|
+
def auto_plan(self, strategy: PlanningStrategy) -> Dict[str, Any]:
|
|
498
|
+
"""
|
|
499
|
+
Generate an auto-planned timeline using the specified strategy.
|
|
500
|
+
|
|
501
|
+
Args:
|
|
502
|
+
strategy: The planning strategy to use
|
|
503
|
+
|
|
504
|
+
Returns:
|
|
505
|
+
Dictionary with the planned timeline and metadata
|
|
506
|
+
"""
|
|
507
|
+
if strategy == PlanningStrategy.SYNCHRONIZED_FINISH:
|
|
508
|
+
step_timings = self.plan_synchronized_finish()
|
|
509
|
+
elif strategy == PlanningStrategy.COVER:
|
|
510
|
+
step_timings = self.plan_cover()
|
|
511
|
+
elif strategy == PlanningStrategy.MINIMIZE_LENGTH:
|
|
512
|
+
step_timings = self.plan_minimize_length()
|
|
513
|
+
else:
|
|
514
|
+
raise ValueError(f"Unknown planning strategy: {strategy}")
|
|
515
|
+
|
|
516
|
+
# Check for resource overutilization
|
|
517
|
+
overutilizations = self._check_resource_overutilization(step_timings)
|
|
518
|
+
|
|
519
|
+
# Calculate total duration
|
|
520
|
+
total_duration = max(timing.end_time for timing in step_timings) if step_timings else 0
|
|
521
|
+
|
|
522
|
+
# Format results
|
|
523
|
+
result = {
|
|
524
|
+
'strategy': strategy.value,
|
|
525
|
+
'totalDuration': total_duration,
|
|
526
|
+
'overutilizations': [
|
|
527
|
+
{
|
|
528
|
+
'resource': res_name,
|
|
529
|
+
'startTime': start_time,
|
|
530
|
+
'endTime': end_time
|
|
531
|
+
}
|
|
532
|
+
for res_name, start_time, end_time in overutilizations
|
|
533
|
+
],
|
|
534
|
+
'stepTimings': [
|
|
535
|
+
{
|
|
536
|
+
'stepId': timing.step_id,
|
|
537
|
+
'trackId': timing.track_id,
|
|
538
|
+
'startTime': timing.start_time,
|
|
539
|
+
'duration': timing.duration,
|
|
540
|
+
'endTime': timing.end_time,
|
|
541
|
+
'isFlex': timing.is_flex,
|
|
542
|
+
'minDuration': timing.min_duration,
|
|
543
|
+
'maxDuration': timing.max_duration,
|
|
544
|
+
'priority': timing.priority,
|
|
545
|
+
'resources': timing.resources
|
|
546
|
+
}
|
|
547
|
+
for timing in step_timings
|
|
548
|
+
]
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
return result
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def auto_plan_program(program_file: str, strategy: str = "cover",
|
|
555
|
+
resource_constraints_file: str = None) -> Dict[str, Any]:
|
|
556
|
+
"""
|
|
557
|
+
Auto-plan a program with the specified strategy.
|
|
558
|
+
|
|
559
|
+
Args:
|
|
560
|
+
program_file: Path to the program JSON file
|
|
561
|
+
strategy: Planning strategy ("synchronized_finish", "cover", "minimize_length")
|
|
562
|
+
resource_constraints_file: Optional path to resource constraints JSON file
|
|
563
|
+
|
|
564
|
+
Returns:
|
|
565
|
+
Dictionary with the planned timeline
|
|
566
|
+
"""
|
|
567
|
+
# Load program
|
|
568
|
+
with open(program_file, 'r') as f:
|
|
569
|
+
program = json.load(f)
|
|
570
|
+
|
|
571
|
+
# Load resource constraints if provided
|
|
572
|
+
resource_constraints = []
|
|
573
|
+
if resource_constraints_file:
|
|
574
|
+
with open(resource_constraints_file, 'r') as f:
|
|
575
|
+
constraints_data = json.load(f)
|
|
576
|
+
resource_constraints = constraints_data.get('resources', [])
|
|
577
|
+
|
|
578
|
+
# Map strategy string to enum
|
|
579
|
+
strategy_map = {
|
|
580
|
+
'synchronized_finish': PlanningStrategy.SYNCHRONIZED_FINISH,
|
|
581
|
+
'cover': PlanningStrategy.COVER,
|
|
582
|
+
'minimize_length': PlanningStrategy.MINIMIZE_LENGTH
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
strategy_enum = strategy_map.get(strategy)
|
|
586
|
+
if not strategy_enum:
|
|
587
|
+
raise ValueError(f"Unknown strategy: {strategy}")
|
|
588
|
+
|
|
589
|
+
# Create planner and generate plan
|
|
590
|
+
planner = AutoPlanner(program, resource_constraints)
|
|
591
|
+
return planner.auto_plan(strategy_enum)
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
if __name__ == "__main__":
|
|
595
|
+
import argparse
|
|
596
|
+
|
|
597
|
+
parser = argparse.ArgumentParser(description="Auto-plan a Rhylthyme program")
|
|
598
|
+
parser.add_argument("program_file", help="Path to program JSON file")
|
|
599
|
+
parser.add_argument("--strategy", choices=["synchronized_finish", "cover", "minimize_length"],
|
|
600
|
+
default="cover", help="Planning strategy")
|
|
601
|
+
parser.add_argument("--resources", help="Path to resource constraints JSON file")
|
|
602
|
+
parser.add_argument("--output", help="Output file for the plan")
|
|
603
|
+
|
|
604
|
+
args = parser.parse_args()
|
|
605
|
+
|
|
606
|
+
try:
|
|
607
|
+
result = auto_plan_program(args.program_file, args.strategy, args.resources)
|
|
608
|
+
|
|
609
|
+
if args.output:
|
|
610
|
+
with open(args.output, 'w') as f:
|
|
611
|
+
json.dump(result, f, indent=2)
|
|
612
|
+
print(f"Plan saved to {args.output}")
|
|
613
|
+
else:
|
|
614
|
+
print(json.dumps(result, indent=2))
|
|
615
|
+
|
|
616
|
+
except Exception as e:
|
|
617
|
+
print(f"Error: {e}")
|
|
618
|
+
exit(1)
|
rhylthyme/cli/cli.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import click
|
|
2
|
+
import sys
|
|
3
|
+
import os
|
|
4
|
+
import json
|
|
5
|
+
|
|
6
|
+
@click.group()
|
|
7
|
+
def cli():
|
|
8
|
+
"""Rhylthyme CLI Runner"""
|
|
9
|
+
pass
|
|
10
|
+
|
|
11
|
+
@cli.command()
|
|
12
|
+
@click.argument('program_file', type=click.Path(exists=True))
|
|
13
|
+
@click.option('--env', '-e', type=click.Path(exists=True), help='Environment file (optional)')
|
|
14
|
+
@click.option('--debug', is_flag=True, help='Enable debug output')
|
|
15
|
+
def run(program_file, env, debug):
|
|
16
|
+
"""Run a Rhylthyme program."""
|
|
17
|
+
click.echo(f"Running program: {program_file}")
|
|
18
|
+
if env:
|
|
19
|
+
click.echo(f"Using environment: {env}")
|
|
20
|
+
if debug:
|
|
21
|
+
click.echo("Debug mode enabled.")
|
|
22
|
+
# Placeholder: Add actual program running logic here
|
|
23
|
+
click.echo("[run] Not yet implemented.")
|
|
24
|
+
|
|
25
|
+
@cli.command()
|
|
26
|
+
@click.argument('input_file', type=click.Path(exists=True))
|
|
27
|
+
@click.option('--type', '-t', type=click.Choice(['program', 'environment']), default='program', help='Type of file to validate')
|
|
28
|
+
def validate(input_file, type):
|
|
29
|
+
"""Validate a Rhylthyme program or environment file against the spec."""
|
|
30
|
+
click.echo(f"Validating {type}: {input_file}")
|
|
31
|
+
import jsonschema
|
|
32
|
+
import importlib.resources
|
|
33
|
+
if type == 'program':
|
|
34
|
+
with open(input_file) as f:
|
|
35
|
+
data = json.load(f)
|
|
36
|
+
# Load schema from rhylthyme-spec package
|
|
37
|
+
import pkg_resources
|
|
38
|
+
schema_path = pkg_resources.resource_filename('rhylthyme_spec', 'schemas/program_schema.json')
|
|
39
|
+
with open(schema_path) as sf:
|
|
40
|
+
schema = json.load(sf)
|
|
41
|
+
try:
|
|
42
|
+
jsonschema.validate(instance=data, schema=schema)
|
|
43
|
+
click.echo("Validation successful.")
|
|
44
|
+
except jsonschema.ValidationError as e:
|
|
45
|
+
click.echo(f"Validation failed: {e.message}")
|
|
46
|
+
sys.exit(1)
|
|
47
|
+
else:
|
|
48
|
+
click.echo("Environment validation not yet implemented.")
|
|
49
|
+
sys.exit(1)
|
|
50
|
+
|
|
51
|
+
if __name__ == "__main__":
|
|
52
|
+
cli()
|