Skip to content

Commit 793667e

Browse files
committed
Resurrecting Sequence Graph Capabilities to Task Class
1 parent b8b2acb commit 793667e

File tree

2 files changed

+153
-3
lines changed

2 files changed

+153
-3
lines changed

famodel/irma/calwave_task1.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
# 1) addAction → structure only (type, name, objects, deps)
44
# 2) evaluateAssets → assign vessels/roles (+ durations/costs)
55
# 3) (schedule/plot handled by your existing tooling)
6-
6+
import matplotlib.pyplot as plt
77
from famodel.project import Project
88
from calwave_irma import Scenario
99
import calwave_chart as chart
@@ -195,7 +195,8 @@ def assign_actions(sc: Scenario, actions: dict):
195195

196196
# 5) Build Task
197197
task1 = Task(name='calwave_task1', actions=sc.actions)
198-
198+
task1.getSequenceGraph()
199+
plt.show()
199200
# task1.updateTaskTime(newStart=10)
200201

201202
# 6) build the chart input directly from the Task and plot #TODO: Rudy / Improve this later (maybe include it in Task.py/Scenario and let it plot the absolute time instead of relative time)

famodel/irma/task.py

Lines changed: 150 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,156 @@ def __init__(self, name, actions, action_sequence=None, **kwargs):
8181
print(f"---------------------- Initializing Task '{self.name} ----------------------")
8282
print(f"Task '{self.name}' initialized with duration = {self.duration:.2f} h.")
8383
print(f"Task '{self.name}' initialized with cost = ${self.cost:.2f} ")
84-
84+
85+
def getSequenceGraph(self, action_sequence=None, plot=True):
86+
'''Generate a multi-directed graph that visalizes action sequencing within the task.
87+
Build a MultiDiGraph with nodes:
88+
Start -> CP1 -> CP2 -> ... -> End
89+
90+
Checkpoints are computed from action "levels":
91+
level(a) = 1 if no prerequisites.
92+
level(a) = 1 + max(level(p) for p in prerequisites) 1 + the largest level among a’s prerequisites.
93+
Number of checkpoints = max(level) - 1.
94+
'''
95+
if action_sequence is None:
96+
action_sequence = self.action_sequence
97+
# Compute levels
98+
levels: dict[str, int] = {}
99+
def level_of(a: str, b: set[str]) -> int:
100+
'''Return the level of action a. b is the set of actions currently being explored'''
101+
102+
# If we have already computed the level, return it
103+
if a in levels:
104+
return levels[a]
105+
106+
if a in b:
107+
raise ValueError(f"Cycle detected in action sequence at '{a}' in task '{self.name}'. The action cannot be its own prerequisite.")
108+
109+
b.add(a)
110+
111+
# Look up prerequisites for action a.
112+
pres = action_sequence.get(a, [])
113+
if not pres:
114+
lv = 1 # No prerequisites, level 1
115+
else:
116+
# If a prerequisites name is not in the dict, treat it as a root (level 1)
117+
lv = 1 + max(level_of(p, b) if p in action_sequence else 1 for p in pres)
118+
# b.remove(a) # if you want to unmark a from the explored dictionary, b, uncomment this line.
119+
levels[a] = lv
120+
return lv
121+
122+
for a in action_sequence:
123+
level_of(a, set())
124+
125+
max_level = max(levels.values(), default=1)
126+
num_cps = max(0, max_level - 1)
127+
128+
H = nx.MultiDiGraph()
129+
130+
# Add the Start -> [checkpoints] -> End nodes
131+
H.add_node("Start")
132+
for i in range(1, num_cps + 1):
133+
H.add_node(f"CP{i}")
134+
H.add_node("End")
135+
136+
shells = [["Start"]]
137+
if num_cps > 0:
138+
# Middle shells
139+
cps = [f"CP{i}" for i in range(1, num_cps + 1)]
140+
shells.append(cps)
141+
shells.append(["End"])
142+
143+
pos = nx.shell_layout(H, nlist=shells)
144+
145+
xmin, xmax = -2.0, 2.0 # maybe would need to change those later on.
146+
pos["Start"] = (xmin, 0)
147+
pos["End"] = (xmax, 0)
148+
149+
# Add action edges
150+
# Convention:
151+
# level 1 actions: Start -> CP1 (or Start -> End if no CPs)
152+
# level L actions (2 <= L < max_level): CP{L-1} -> CP{L}
153+
# level == max_level actions: CP{num_cps} -> End
154+
for action, lv in levels.items():
155+
action = self.actions[action]
156+
if num_cps == 0:
157+
# No checkpoints: all actions from Start to End
158+
H.add_edge("Start", "End", key=action, duration=action.duration, cost=action.cost)
159+
else:
160+
if lv == 1:
161+
H.add_edge("Start", "CP1", key=action, duration=action.duration, cost=action.cost)
162+
elif lv < max_level:
163+
H.add_edge(f"CP{lv-1}", f"CP{lv}", key=action, duration=action.duration, cost=action.cost)
164+
else: # lv == max_level
165+
H.add_edge(f"CP{num_cps}", "End", key=action, duration=action.duration, cost=action.cost)
166+
# 3. Compute cumulative start time for each level
167+
level_groups = {}
168+
for action, lv in levels.items():
169+
level_groups.setdefault(lv, []).append(action)
170+
171+
level_durations = {lv: max(self.actions[a].duration for a in acts)
172+
for lv, acts in level_groups.items()}
173+
174+
175+
task_duration = sum(level_durations.values())
176+
level_start_time = {}
177+
elapsed = 0.0
178+
cp_string = []
179+
for lv in range(1, max_level + 1):
180+
level_start_time[lv] = elapsed
181+
elapsed += level_durations.get(lv, 0.0)
182+
# also collect all actions at this level for title
183+
acts = [a for a, l in levels.items() if l == lv]
184+
if acts and lv <= num_cps:
185+
cp_string.append(f"CP{lv}: {', '.join(acts)}")
186+
elif acts and lv > num_cps:
187+
cp_string.append(f"End: {', '.join(acts)}")
188+
# Assign to self:
189+
self.sequence_graph = H
190+
title_str = f"Task {self.name}. Duration {self.duration:.2f} : " + " | ".join(cp_string)
191+
if plot:
192+
fig, ax = plt.subplots()
193+
# pos = nx.shell_layout(G)
194+
nx.draw(H, pos, with_labels=True, node_size=500, node_color="lightblue", edge_color='white')
195+
196+
label_positions = {} # to store label positions for each edge
197+
# Group edges by unique (u, v) pairs
198+
for (u, v) in set((u, v) for u, v, _ in H.edges(keys=True)):
199+
# get all edges between u and v (dict keyed by edge key)
200+
edge_dict = H.get_edge_data(u, v) # {key: {attrdict}, ...}
201+
n = len(edge_dict)
202+
203+
# curvature values spread between -0.3 and +0.3 [helpful to visualize multiple edges]
204+
if n==1:
205+
rads = [0]
206+
offsets = [0.5]
207+
else:
208+
rads = np.linspace(-0.3, 0.3, n)
209+
offsets = np.linspace(0.2, 0.8, n)
210+
211+
# draw each edge
212+
durations = [d.get("duration", 0.0) for d in edge_dict.values()]
213+
scale = max(max(durations), 0.0001) # avoid div by zero
214+
width_scale = 4.0 / scale # normalize largest to ~4px
215+
216+
for rad, offset, (k, d) in zip(rads, offsets, edge_dict.items()):
217+
nx.draw_networkx_edges(
218+
H, pos, edgelist=[(u, v)], ax=ax,
219+
connectionstyle=f"arc3,rad={rad}",
220+
arrows=True, arrowstyle="-|>",
221+
edge_color="gray",
222+
width=max(0.5, d.get("duration", []) * width_scale),
223+
)
224+
label_positions[(u, v, k)] = offset # store position for edge label
225+
226+
ax.set_title(title_str, fontsize=12, fontweight="bold")
227+
ax.axis("off")
228+
plt.tight_layout()
229+
230+
return H
231+
232+
233+
85234
def stageActions(self, from_deps=True):
86235
'''
87236
This method stages the action_sequence for a proper execution order.

0 commit comments

Comments
 (0)